新增宣传页 Mock 模块并接入首页路由。

从 xh_test 同步 ScenicPromotion 演示页,补齐 MVVM 结构、本地资源、单元测试与模块文档,注册 promotion_page 首页入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 15:29:25 +08:00
parent a528d9b0d7
commit 1b1e93b9ca
30 changed files with 2661 additions and 4 deletions

View File

@ -0,0 +1,215 @@
//
// ScenicPromotionModels.swift
// suixinkan
//
// Created by Codex on 2026/7/2.
//
import Foundation
import SwiftUI
// MARK: -
///
enum ScenicPromotionPageState: Equatable {
case idle
case loading
case normal
case empty
case error(String)
}
// MARK: -
/// Loading / Empty / Error
enum ScenicPromotionDemoScenario: String, CaseIterable, Identifiable {
case normal
case noMedia
case noQueue
case loading
case error
case empty
var id: String { rawValue }
var title: String {
switch self {
case .normal: return "正常"
case .noMedia: return "无媒体"
case .noQueue: return "无排队"
case .loading: return "加载"
case .error: return "异常"
case .empty: return "空数据"
}
}
}
// MARK: -
/// /
enum ScenicPromotionMediaMode: String, CaseIterable, Identifiable {
case video
case photo
var id: String { rawValue }
var title: String {
switch self {
case .video: return "视频"
case .photo: return "照片"
}
}
var iconName: String {
switch self {
case .video: return "play.circle.fill"
case .photo: return "photo.fill"
}
}
}
// MARK: -
///
enum ScenicPromotionSettingsPageState: Equatable {
case idle
case loading
case normal
case empty
case error(String)
}
// MARK: -
///
enum ScenicPromotionSettingsDemoScenario: String, CaseIterable, Identifiable {
case normal
case loading
case empty
case error
var id: String { rawValue }
var title: String {
switch self {
case .normal: return "正常"
case .loading: return "加载"
case .empty: return "空数据"
case .error: return "异常"
}
}
}
// MARK: -
///
enum ScenicPromotionUploadKind: String, CaseIterable, Identifiable {
case video
case image
var id: String { rawValue }
var title: String {
switch self {
case .video: return "视频宣传素材"
case .image: return "图片素材"
}
}
var shortTitle: String {
switch self {
case .video: return "视频"
case .image: return "图片"
}
}
var uploadTitle: String {
switch self {
case .video: return "上传视频宣传素材"
case .image: return "上传图片素材"
}
}
var emptyTitle: String {
switch self {
case .video: return "暂无视频宣传素材"
case .image: return "暂无图片素材"
}
}
var iconName: String {
switch self {
case .video: return "video.fill"
case .image: return "photo.on.rectangle.angled"
}
}
}
// MARK: -
/// Mock
struct ScenicPromotionBoard: Equatable {
let scenicName: String
let subtitle: String
let qrCodeTitle: String
let spots: [ScenicPromotionSpot]
}
/// /
struct ScenicPromotionSpot: Identifiable, Equatable {
let id: String
let name: String
let iconName: String
let queue: ScenicPromotionQueueInfo?
let videos: [ScenicPromotionMedia]
let photos: [ScenicPromotionMedia]
}
///
struct ScenicPromotionQueueInfo: Equatable {
let waitingPeople: Int
let estimatedMinutes: Int
let averageMinutes: Int
let nextNumber: String
let currentNumber: String
let shootingDuration: String
}
///
struct ScenicPromotionMedia: Identifiable, Hashable {
let id: String
let mode: ScenicPromotionMediaMode
let title: String
let subtitle: String
let duration: String?
let palette: [UInt]
let assetName: String?
let heroAssetName: String?
let previewWidth: CGFloat
}
/// 使
struct ScenicPromotionQueuePoint: Identifiable, Hashable {
let id: String
let name: String
let areaName: String
}
/// /
struct ScenicPromotionMaterialPoint: Identifiable, Hashable {
let id: String
let name: String
let areaName: String
}
/// Mock
struct ScenicPromotionManagedMaterial: Identifiable, Hashable {
let id: String
let pointID: String
let kind: ScenicPromotionUploadKind
let title: String
let subtitle: String
let assetName: String
let duration: String?
let createdAt: String
}

View File

@ -0,0 +1,79 @@
# ScenicPromotion 模块业务逻辑
## 模块职责
ScenicPromotion 模块负责首页 `promotion_page` 入口,为摄影师和景区运营提供景区宣传与排队导览 Demo 页面。
当前阶段使用 Mock 数据完成业务闭环,不接入真实后端接口、真实视频地址或真实二维码。
## 页面流程
```mermaid
flowchart TD
Home[宣传页主页面] -->|QR 三连击 + 密码 1| Settings[宣传页设置]
Home -->|缩略图点击| Hero[底部主视觉联动]
Settings --> Preview[素材预览]
Settings -->|Mock 上传/删除| Settings
Settings -->|提交| Submit[Mock 提交结果]
```
## 核心实体
| 类型 | 说明 |
| --- | --- |
| `ScenicPromotionBoard` | 宣传页看板:景区名、副标题、打卡点列表 |
| `ScenicPromotionSpot` | 单个打卡点:排队信息 + 视频/照片素材 |
| `ScenicPromotionQueueInfo` | 排队人数、等待时间、叫号信息 |
| `ScenicPromotionMedia` | 横向素材与主视觉共用数据 |
| `ScenicPromotionQueuePoint` | 设置页排队打卡点(多选绑定) |
| `ScenicPromotionMaterialPoint` | 设置页素材所属打卡点(单选,与排队点独立) |
| `ScenicPromotionManagedMaterial` | 设置页已上传 Mock 素材 |
## 数据来源
- 全部 Mock 数据由 `ScenicPromotionMockStore` 提供种子数据。
- `ScenicPromotionViewModel` 驱动主页面状态与素材选中。
- `ScenicPromotionSettingsViewModel` 独立管理设置页排队绑定与素材上传。
## 主页面规则
1. 打卡点由接口返回列表动态展示Mock 含网红桥、汉地拔冲、空中草原、河谷花海、雪山栈道。
2. 5 个以内等分展示,超过 5 个时一屏露出 5 个并横向滚动。
3. 缩略图点击只联动底部主视觉,不跳转二级页。
4. 视频主视觉点击仅展示模拟播放提示。
5. 二维码快速点击 3 次1.1 秒内)→ 密码弹窗 → 输入 `1` 进入设置页。
6. 长按二维码可切换演示状态(正常 / 无媒体 / 无排队 / 加载 / 异常 / 空数据)。
## 设置页规则
1. 排队打卡点支持多选绑定;提交前至少选一个。
2. 素材所属打卡点与排队打卡点是两套独立数据。
3. 视频/图片上传前必须先选择素材所属打卡点。
4. 上传、删除、提交均为 Mock 本地状态变更。
## 路由
- 权限 URI`promotion_page`
- 首页路由:`HomeRoute.scenicPromotion`
- 入口依赖权限下发或用户手动添加到常用应用(`HomeCommonMenuStore.androidHomeMenuURIs` 白名单已包含该 URI
## 与相邻模块的边界
| 模块 | URI | 用途 | 后端 |
| --- | --- | --- | --- |
| ScenicPromotion | `promotion_page` | 景区宣传与排队导览 Demo | Mock |
| QueueManagement | `queue_management` | 真实排队叫号管理 | 真实 API |
| Assets | `asset_management` | 素材库管理 | 真实 API |
## 测试要求
- `ScenicPromotionTests`:主页面与设置页 ViewModel 成功路径、场景切换、上传校验。
- `HomeMenuRouterTests``promotion_page` 路由映射。
## 主要文件
- `Models/ScenicPromotionModels.swift` — 状态枚举与数据实体
- `Services/ScenicPromotionMockStore.swift` — Mock 种子数据
- `ViewModels/ScenicPromotionViewModels.swift` — 主页面与设置页 ViewModel
- `Views/ScenicPromotionViews.swift` — 主页面
- `Views/ScenicPromotionSettingsViews.swift` — 设置页与素材预览

View File

@ -0,0 +1,360 @@
//
// ScenicPromotionMockStore.swift
// suixinkan
//
// Created by Codex on 2026/7/2.
//
import Foundation
/// Mock
enum ScenicPromotionMockStore {
/// Mock
static let mockBoard = ScenicPromotionBoard(
scenicName: "那拉提景区",
subtitle: "实时查看打卡点排队信息",
qrCodeTitle: "扫码查看排队导览",
spots: [
ScenicPromotionSpot(
id: "bridge",
name: "网红桥",
iconName: "bridge.2",
queue: ScenicPromotionQueueInfo(
waitingPeople: 54,
estimatedMinutes: 18,
averageMinutes: 16,
nextNumber: "B055",
currentNumber: "B054",
shootingDuration: "10分钟30秒"
),
videos: [
ScenicPromotionMedia(
id: "bridge_video_1",
mode: .video,
title: "网红桥",
subtitle: "雪山下的蓝色木桥,旅拍打卡热门点",
duration: "00:32",
palette: [0x1D8BFF, 0x8FD7FF, 0x2F8F45],
assetName: "ScenicPromotionThumbPortrait",
heroAssetName: "ScenicPromotionHero",
previewWidth: 72
),
ScenicPromotionMedia(
id: "bridge_video_2",
mode: .video,
title: "汉地拔冲",
subtitle: "雪山草甸与观景石同框",
duration: "00:28",
palette: [0x2A7BEF, 0xB7E5FF, 0x74B84A],
assetName: "ScenicPromotionThumbMarker",
heroAssetName: nil,
previewWidth: 144
),
ScenicPromotionMedia(
id: "bridge_video_3",
mode: .video,
title: "草甸旅拍",
subtitle: "绿色草坡上的打卡瞬间",
duration: "00:22",
palette: [0x2A7BEF, 0xB7E5FF, 0x74B84A],
assetName: "ScenicPromotionThumbGrass",
heroAssetName: nil,
previewWidth: 72
),
ScenicPromotionMedia(
id: "bridge_video_4",
mode: .video,
title: "骑马路线",
subtitle: "游客骑行与雪山远景",
duration: "00:24",
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
assetName: "ScenicPromotionThumbHorse",
heroAssetName: nil,
previewWidth: 72
)
],
photos: [
ScenicPromotionMedia(
id: "bridge_photo_1",
mode: .photo,
title: "桥边人像",
subtitle: "白裙、花束、雪山背景",
duration: nil,
palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C],
assetName: "ScenicPromotionThumbPortrait",
heroAssetName: nil,
previewWidth: 72
),
ScenicPromotionMedia(
id: "bridge_photo_2",
mode: .photo,
title: "全景草甸",
subtitle: "高山草甸与云层层次",
duration: nil,
palette: [0x3CA7FF, 0xC8F1FF, 0x4AAE57],
assetName: "ScenicPromotionThumbMarker",
heroAssetName: nil,
previewWidth: 144
),
ScenicPromotionMedia(
id: "bridge_photo_3",
mode: .photo,
title: "骑马路线",
subtitle: "游客骑行与雪山远景",
duration: nil,
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
assetName: "ScenicPromotionThumbHorse",
heroAssetName: nil,
previewWidth: 72
),
ScenicPromotionMedia(
id: "bridge_photo_4",
mode: .photo,
title: "草甸旅拍",
subtitle: "绿色草坡上的打卡瞬间",
duration: nil,
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
assetName: "ScenicPromotionThumbGrass",
heroAssetName: nil,
previewWidth: 72
)
]
),
ScenicPromotionSpot(
id: "hendi",
name: "汉地拔冲",
iconName: "mountain.2.fill",
queue: ScenicPromotionQueueInfo(
waitingPeople: 32,
estimatedMinutes: 12,
averageMinutes: 14,
nextNumber: "H036",
currentNumber: "H035",
shootingDuration: "8分钟20秒"
),
videos: [
ScenicPromotionMedia(
id: "hendi_video_1",
mode: .video,
title: "汉地拔冲",
subtitle: "雪山下的绿色画卷,旅拍打卡圣地",
duration: "00:32",
palette: [0x0B78F6, 0xB4E8FF, 0x4E9D3B],
assetName: "ScenicPromotionThumbMarker",
heroAssetName: "ScenicPromotionHero",
previewWidth: 144
)
],
photos: [
ScenicPromotionMedia(
id: "hendi_photo_1",
mode: .photo,
title: "雪山草原",
subtitle: "开阔草原与雪山远景",
duration: nil,
palette: [0x167CFF, 0xBDEBFF, 0x58B15D],
assetName: "ScenicPromotionThumbGrass",
heroAssetName: nil,
previewWidth: 72
),
ScenicPromotionMedia(
id: "hendi_photo_2",
mode: .photo,
title: "观景石",
subtitle: "点位标识与远山构图",
duration: nil,
palette: [0x1D6BD8, 0xA7DBFF, 0x769A5A],
assetName: "ScenicPromotionThumbMarker",
heroAssetName: nil,
previewWidth: 144
)
]
),
ScenicPromotionSpot(
id: "grassland",
name: "空中草原",
iconName: "leaf.fill",
queue: ScenicPromotionQueueInfo(
waitingPeople: 21,
estimatedMinutes: 9,
averageMinutes: 13,
nextNumber: "G022",
currentNumber: "G021",
shootingDuration: "7分钟40秒"
),
videos: [
ScenicPromotionMedia(
id: "grassland_video_1",
mode: .video,
title: "空中草原",
subtitle: "草甸花海与远山同框",
duration: "00:26",
palette: [0x2A7BEF, 0xB7E5FF, 0x74B84A],
assetName: "ScenicPromotionThumbGrass",
heroAssetName: nil,
previewWidth: 96
)
],
photos: [
ScenicPromotionMedia(
id: "grassland_photo_1",
mode: .photo,
title: "草原花海",
subtitle: "绿色草坡上的花海样片",
duration: nil,
palette: [0x3CA7FF, 0xC8F1FF, 0x4AAE57],
assetName: "ScenicPromotionThumbGrass",
heroAssetName: nil,
previewWidth: 96
)
]
),
ScenicPromotionSpot(
id: "valley",
name: "河谷花海",
iconName: "camera.macro",
queue: ScenicPromotionQueueInfo(
waitingPeople: 18,
estimatedMinutes: 8,
averageMinutes: 12,
nextNumber: "V019",
currentNumber: "V018",
shootingDuration: "6分钟50秒"
),
videos: [
ScenicPromotionMedia(
id: "valley_video_1",
mode: .video,
title: "河谷花海",
subtitle: "花海步道与雪山远景",
duration: "00:24",
palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C],
assetName: "ScenicPromotionThumbPortrait",
heroAssetName: nil,
previewWidth: 96
)
],
photos: [
ScenicPromotionMedia(
id: "valley_photo_1",
mode: .photo,
title: "花海人像",
subtitle: "花海前景与游客人像",
duration: nil,
palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C],
assetName: "ScenicPromotionThumbPortrait",
heroAssetName: nil,
previewWidth: 96
)
]
),
ScenicPromotionSpot(
id: "snowtrail",
name: "雪山栈道",
iconName: "snowflake",
queue: ScenicPromotionQueueInfo(
waitingPeople: 27,
estimatedMinutes: 11,
averageMinutes: 15,
nextNumber: "S028",
currentNumber: "S027",
shootingDuration: "9分钟10秒"
),
videos: [
ScenicPromotionMedia(
id: "snowtrail_video_1",
mode: .video,
title: "雪山栈道",
subtitle: "木栈道与雪山背景",
duration: "00:29",
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
assetName: "ScenicPromotionThumbHorse",
heroAssetName: nil,
previewWidth: 96
)
],
photos: [
ScenicPromotionMedia(
id: "snowtrail_photo_1",
mode: .photo,
title: "栈道远眺",
subtitle: "雪山、栈道、游客同框",
duration: nil,
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
assetName: "ScenicPromotionThumbHorse",
heroAssetName: nil,
previewWidth: 96
)
]
)
]
)
/// Mock
static let mockQueuePoints: [ScenicPromotionQueuePoint] = [
ScenicPromotionQueuePoint(id: "queue_bridge", name: "网红桥排队点", areaName: "那拉提景区"),
ScenicPromotionQueuePoint(id: "queue_hendi", name: "汉地拔冲排队点", areaName: "那拉提景区"),
ScenicPromotionQueuePoint(id: "queue_grass", name: "草甸旅拍排队点", areaName: "那拉提景区")
]
/// Mock
static let mockMaterialPoints: [ScenicPromotionMaterialPoint] = [
ScenicPromotionMaterialPoint(id: "material_bridge", name: "网红桥", areaName: "宣传素材点"),
ScenicPromotionMaterialPoint(id: "material_hendi", name: "汉地拔冲", areaName: "宣传素材点"),
ScenicPromotionMaterialPoint(id: "material_grass", name: "空中草原", areaName: "宣传素材点")
]
/// Mock
static let mockMaterialsByPointID: [String: [ScenicPromotionManagedMaterial]] = [
"material_bridge": [
ScenicPromotionManagedMaterial(
id: "material_bridge_video_1",
pointID: "material_bridge",
kind: .video,
title: "网红桥视频1",
subtitle: "雪山草甸旅拍视频",
assetName: "ScenicPromotionHero",
duration: "00:32",
createdAt: "2026-06-25 18:20"
),
ScenicPromotionManagedMaterial(
id: "material_bridge_image_1",
pointID: "material_bridge",
kind: .image,
title: "网红桥图片1",
subtitle: "游客桥边人像样片",
assetName: "ScenicPromotionThumbPortrait",
duration: nil,
createdAt: "2026-06-25 18:24"
)
],
"material_hendi": [
ScenicPromotionManagedMaterial(
id: "material_hendi_image_1",
pointID: "material_hendi",
kind: .image,
title: "汉地拔冲图片1",
subtitle: "观景石与雪山远景",
assetName: "ScenicPromotionThumbMarker",
duration: nil,
createdAt: "2026-06-25 18:30"
)
]
]
/// Mock
static func assetName(for kind: ScenicPromotionUploadKind, index: Int) -> String {
switch kind {
case .video:
return index.isMultiple(of: 2) ? "ScenicPromotionThumbMarker" : "ScenicPromotionHero"
case .image:
let assets = [
"ScenicPromotionThumbPortrait",
"ScenicPromotionThumbGrass",
"ScenicPromotionThumbHorse",
"ScenicPromotionThumbMarker"
]
return assets[(index - 1) % assets.count]
}
}
}

View File

@ -0,0 +1,270 @@
//
// ScenicPromotionViewModels.swift
// suixinkan
//
// Created by Codex on 2026/7/2.
//
import Combine
import Foundation
import SwiftUI
/// ViewModel
@MainActor
final class ScenicPromotionViewModel: ObservableObject {
@Published private(set) var pageState: ScenicPromotionPageState = .idle
@Published private(set) var board: ScenicPromotionBoard?
@Published var selectedSpotID: String?
@Published var selectedMode: ScenicPromotionMediaMode = .video
@Published var selectedMediaID: ScenicPromotionMedia.ID?
@Published private(set) var activeScenario: ScenicPromotionDemoScenario = .normal
/// selectedSpotID
var selectedSpot: ScenicPromotionSpot? {
guard let board else { return nil }
return board.spots.first { $0.id == selectedSpotID } ?? board.spots.first
}
/// nil
var queue: ScenicPromotionQueueInfo? {
guard activeScenario != .noQueue else { return nil }
return selectedSpot?.queue
}
/// /
var mediaItems: [ScenicPromotionMedia] {
guard activeScenario != .noMedia else { return [] }
return Self.mediaItems(for: selectedSpot, mode: selectedMode)
}
/// ID
var selectedMedia: ScenicPromotionMedia? {
let items = mediaItems
return items.first { $0.id == selectedMediaID } ?? items.first
}
var hasQueueData: Bool {
queue != nil
}
/// Mock
func loadIfNeeded() async {
guard pageState == .idle else { return }
await load()
}
/// 便 Loading
func load() async {
withAnimation(.easeInOut(duration: 0.25)) {
pageState = .loading
}
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
applyScenario(.normal)
}
///
func applyScenario(_ scenario: ScenicPromotionDemoScenario) {
activeScenario = scenario
withAnimation(.easeInOut(duration: 0.28)) {
switch scenario {
case .normal, .noMedia, .noQueue:
board = ScenicPromotionMockStore.mockBoard
selectedSpotID = selectedSpotID ?? board?.spots.first?.id
selectedMediaID = Self.mediaItems(for: selectedSpot, mode: selectedMode).first?.id
pageState = .normal
case .loading:
pageState = .loading
case .error:
board = nil
pageState = .error("宣传页数据加载失败,请稍后重试")
case .empty:
board = nil
pageState = .empty
}
}
}
///
func selectSpot(_ spot: ScenicPromotionSpot) {
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
selectedSpotID = spot.id
selectedMediaID = Self.mediaItems(for: spot, mode: selectedMode).first?.id
}
}
/// /
func selectMode(_ mode: ScenicPromotionMediaMode) {
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
selectedMode = mode
selectedMediaID = Self.mediaItems(for: selectedSpot, mode: mode).first?.id
}
}
///
func selectMedia(_ media: ScenicPromotionMedia) {
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
selectedMediaID = media.id
}
}
///
func moveMedia(_ offset: Int) {
let items = mediaItems
guard !items.isEmpty else { return }
let currentIndex = items.firstIndex { $0.id == selectedMedia?.id } ?? 0
let nextIndex = (currentIndex + offset + items.count) % items.count
selectMedia(items[nextIndex])
}
///
static func mediaItems(
for spot: ScenicPromotionSpot?,
mode: ScenicPromotionMediaMode
) -> [ScenicPromotionMedia] {
switch mode {
case .video:
return spot?.videos ?? []
case .photo:
return spot?.photos ?? []
}
}
}
/// ViewModel
@MainActor
final class ScenicPromotionSettingsViewModel: ObservableObject {
@Published private(set) var pageState: ScenicPromotionSettingsPageState = .idle
@Published private(set) var queuePoints: [ScenicPromotionQueuePoint] = []
@Published private(set) var materialPoints: [ScenicPromotionMaterialPoint] = []
@Published var selectedQueuePointIDs: Set<String> = []
@Published var selectedMaterialPointID: String?
@Published private(set) var materialsByPointID: [String: [ScenicPromotionManagedMaterial]] = [:]
///
var selectedQueuePointNames: String {
let names = queuePoints
.filter { selectedQueuePointIDs.contains($0.id) }
.map(\.name)
return names.isEmpty ? "请选择排队打卡点" : names.joined(separator: "")
}
///
var selectedMaterialPoint: ScenicPromotionMaterialPoint? {
guard let selectedMaterialPointID else { return nil }
return materialPoints.first { $0.id == selectedMaterialPointID }
}
///
var selectedMaterialPointName: String {
selectedMaterialPoint?.name ?? "请选择素材所属打卡点"
}
/// Mock
func loadIfNeeded() async {
guard pageState == .idle else { return }
await load()
}
///
func load() async {
withAnimation(.easeInOut(duration: 0.24)) {
pageState = .loading
}
try? await Task.sleep(nanoseconds: 260_000_000)
guard !Task.isCancelled else { return }
applyScenario(.normal)
}
///
func applyScenario(_ scenario: ScenicPromotionSettingsDemoScenario) {
withAnimation(.easeInOut(duration: 0.24)) {
switch scenario {
case .normal:
queuePoints = ScenicPromotionMockStore.mockQueuePoints
materialPoints = ScenicPromotionMockStore.mockMaterialPoints
selectedQueuePointIDs = ["queue_bridge", "queue_hendi"]
selectedMaterialPointID = selectedMaterialPointID ?? ScenicPromotionMockStore.mockMaterialPoints.first?.id
materialsByPointID = ScenicPromotionMockStore.mockMaterialsByPointID
pageState = .normal
case .loading:
pageState = .loading
case .empty:
queuePoints = []
materialPoints = []
selectedQueuePointIDs = []
selectedMaterialPointID = nil
materialsByPointID = [:]
pageState = .empty
case .error:
queuePoints = []
materialPoints = []
selectedQueuePointIDs = []
selectedMaterialPointID = nil
materialsByPointID = [:]
pageState = .error("宣传页设置数据加载失败,请稍后重试")
}
}
}
///
func toggleQueuePoint(_ point: ScenicPromotionQueuePoint) {
withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) {
if selectedQueuePointIDs.contains(point.id) {
selectedQueuePointIDs.remove(point.id)
} else {
selectedQueuePointIDs.insert(point.id)
}
}
}
///
func selectMaterialPoint(_ point: ScenicPromotionMaterialPoint) {
withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) {
selectedMaterialPointID = point.id
}
}
///
func materials(for kind: ScenicPromotionUploadKind) -> [ScenicPromotionManagedMaterial] {
guard let selectedMaterialPointID else { return [] }
return (materialsByPointID[selectedMaterialPointID] ?? [])
.filter { $0.kind == kind }
}
/// false
func addMaterial(kind: ScenicPromotionUploadKind) -> Bool {
guard let point = selectedMaterialPoint else { return false }
let currentItems = materialsByPointID[point.id] ?? []
let nextIndex = currentItems.filter { $0.kind == kind }.count + 1
let assetName = ScenicPromotionMockStore.assetName(for: kind, index: nextIndex)
let material = ScenicPromotionManagedMaterial(
id: "\(point.id)_\(kind.rawValue)_\(UUID().uuidString)",
pointID: point.id,
kind: kind,
title: "\(point.name)\(kind.shortTitle)\(nextIndex)",
subtitle: kind == .video ? "模拟上传视频,待后续接入真实素材" : "模拟上传图片,待后续接入真实素材",
assetName: assetName,
duration: kind == .video ? "00:\(String(format: "%02d", 20 + nextIndex * 3))" : nil,
createdAt: "2026-06-25 19:\(String(format: "%02d", 20 + nextIndex))"
)
withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) {
materialsByPointID[point.id, default: []].append(material)
}
return true
}
///
func deleteMaterial(_ material: ScenicPromotionManagedMaterial) {
withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) {
materialsByPointID[material.pointID]?.removeAll { $0.id == material.id }
}
}
///
func submit() -> Bool {
guard !selectedQueuePointIDs.isEmpty else { return false }
return true
}
}

View File

@ -0,0 +1,598 @@
//
// ScenicPromotionSettingsViews.swift
// suixinkan
//
// Created by Codex on 2026/7/2.
//
import SwiftUI
// MARK: -
struct ScenicPromotionSettingsView: View {
// ViewModel
@StateObject private var viewModel = ScenicPromotionSettingsViewModel()
//
@State private var previewMaterial: ScenicPromotionManagedMaterial?
//
@State private var showSelectMaterialPointAlert = false
//
@State private var showSubmitAlert = false
@State private var submitMessage = ""
var body: some View {
ZStack {
AppDesign.background.ignoresSafeArea()
Group {
switch viewModel.pageState {
case .idle, .loading:
settingsLoadingView
.transition(.opacity.combined(with: .scale(scale: 0.98)))
case .empty:
settingsFullStateView(
icon: "tray",
title: "暂无设置数据",
message: "当前没有可配置的排队打卡点和素材打卡点",
buttonTitle: "重新加载"
) {
Task { await viewModel.load() }
}
.transition(.opacity.combined(with: .move(edge: .bottom)))
case .error(let message):
settingsFullStateView(
icon: "wifi.exclamationmark",
title: "加载失败",
message: message,
buttonTitle: "重试"
) {
Task { await viewModel.load() }
}
.transition(.opacity.combined(with: .move(edge: .bottom)))
case .normal:
normalContent
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.animation(.easeInOut(duration: 0.24), value: viewModel.pageState)
}
.navigationTitle("宣传页设置")
.navigationBarTitleDisplayMode(.inline)
.appNavigationDestination(item: $previewMaterial) { material in
ScenicPromotionMaterialPreviewView(material: material)
}
.safeAreaInset(edge: .bottom) {
if case .normal = viewModel.pageState {
submitBar
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
ForEach(ScenicPromotionSettingsDemoScenario.allCases) { scenario in
Button(scenario.title) {
viewModel.applyScenario(scenario)
}
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 18, weight: .semibold))
}
.accessibilityLabel("设置页演示状态")
}
}
.alert("请选择素材打卡点", isPresented: $showSelectMaterialPointAlert) {
Button("知道了", role: .cancel) {}
} message: {
Text("视频宣传素材和图片素材上传前,需要先选择素材所属打卡点。")
}
.alert("提交结果", isPresented: $showSubmitAlert) {
Button("知道了", role: .cancel) {}
} message: {
Text(submitMessage)
}
.task {
await viewModel.loadIfNeeded()
}
}
//
private var normalContent: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
settingsHeader
queueBindingCard
materialPointCard
materialUploadCard(kind: .video)
materialUploadCard(kind: .image)
}
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, 28)
}
}
//
private var settingsHeader: some View {
HStack(spacing: 14) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.frame(width: 52, height: 52)
.background(.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 16))
VStack(alignment: .leading, spacing: 6) {
Text("宣传页内容设置")
.font(.system(size: 20, weight: .heavy))
.foregroundStyle(.white)
Text("配置排队绑定、素材所属点位和本地演示素材")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.86))
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
LinearGradient(
colors: [Color(hex: 0x087BFF), Color(hex: 0x0C56D8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 18)
)
}
//
private var queueBindingCard: some View {
settingsCard(
title: "排队打卡点绑定",
subtitle: "可多选,用于宣传页展示排队动态",
icon: "checklist.checked",
tint: AppDesign.primary
) {
Menu {
ForEach(viewModel.queuePoints) { point in
Button {
viewModel.toggleQueuePoint(point)
} label: {
Label(
"\(point.name) · \(point.areaName)",
systemImage: viewModel.selectedQueuePointIDs.contains(point.id) ? "checkmark.circle.fill" : "circle"
)
}
}
} label: {
selectionField(
title: viewModel.selectedQueuePointNames,
subtitle: "\(viewModel.selectedQueuePointIDs.count) 个已选择",
icon: "chevron.down"
)
}
.buttonStyle(.plain)
if viewModel.selectedQueuePointIDs.isEmpty {
inlineNotice("提交前至少选择一个排队打卡点", tint: AppDesign.warning)
}
}
}
//
private var materialPointCard: some View {
settingsCard(
title: "素材所属打卡点",
subtitle: "视频和图片上传前必须先选择,和上方排队绑定点分开管理",
icon: "mappin.and.ellipse",
tint: Color(hex: 0x22A06B)
) {
Menu {
ForEach(viewModel.materialPoints) { point in
Button {
viewModel.selectMaterialPoint(point)
} label: {
Label(
"\(point.name) · \(point.areaName)",
systemImage: viewModel.selectedMaterialPointID == point.id ? "checkmark.circle.fill" : "circle"
)
}
}
} label: {
selectionField(
title: viewModel.selectedMaterialPointName,
subtitle: viewModel.selectedMaterialPoint == nil ? "未选择" : "已展示当前点位素材",
icon: "chevron.down"
)
}
.buttonStyle(.plain)
}
}
@ViewBuilder
// /
private func materialUploadCard(kind: ScenicPromotionUploadKind) -> some View {
let materials = viewModel.materials(for: kind)
settingsCard(
title: kind.title,
subtitle: viewModel.selectedMaterialPoint == nil ? "先选择素材所属打卡点后再上传" : "当前展示 \(viewModel.selectedMaterialPointName) 已上传素材",
icon: kind.iconName,
tint: kind == .video ? Color(hex: 0x7A4DFF) : AppDesign.primary
) {
HStack {
Text(viewModel.selectedMaterialPointName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.78)
Spacer()
Button {
upload(kind)
} label: {
Label(kind.uploadTitle, systemImage: "square.and.arrow.up")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.frame(height: 34)
.background(
viewModel.selectedMaterialPoint == nil
? Color(hex: 0xAAB7C7)
: AppDesign.primary,
in: Capsule()
)
}
.buttonStyle(.plain)
}
if viewModel.selectedMaterialPoint == nil {
inlineNotice("请选择素材所属打卡点后再上传\(kind.shortTitle)", tint: AppDesign.warning)
} else if materials.isEmpty {
materialEmptyView(kind)
} else {
VStack(spacing: 10) {
ForEach(materials) { material in
materialRow(material)
}
}
}
}
}
//
private func upload(_ kind: ScenicPromotionUploadKind) {
guard viewModel.addMaterial(kind: kind) else {
showSelectMaterialPointAlert = true
return
}
}
//
private func handleSubmit() {
if viewModel.submit() {
submitMessage = "宣传页设置已保存,当前为 Mock 演示数据。"
} else {
submitMessage = "请至少绑定一个排队打卡点。"
}
showSubmitAlert = true
}
//
private func settingsCard<Content: View>(
title: String,
subtitle: String,
icon: String,
tint: Color,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(tint)
.frame(width: 40, height: 40)
.background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.system(size: 18, weight: .heavy))
.foregroundStyle(AppDesign.textPrimary)
Text(subtitle)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
content()
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(AppDesign.border, lineWidth: 1)
)
}
//
private func selectionField(title: String, subtitle: String, icon: String) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(2)
.multilineTextAlignment(.leading)
.minimumScaleFactor(0.82)
Text(subtitle)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer(minLength: 0)
Image(systemName: icon)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppDesign.primary)
.frame(width: 30, height: 30)
.background(AppDesign.primarySoft, in: Circle())
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color(hex: 0xCFE1FF), lineWidth: 1)
)
}
//
private func inlineNotice(_ text: String, tint: Color) -> some View {
Label(text, systemImage: "exclamationmark.circle.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(tint)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
}
// /
private func materialEmptyView(_ kind: ScenicPromotionUploadKind) -> some View {
VStack(spacing: 8) {
Image(systemName: kind.iconName)
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 58, height: 58)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 16))
Text(kind.emptyTitle)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
Text("点击上传后会把素材保存到当前选择的打卡点")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
.background(Color(hex: 0xF8FAFD), in: RoundedRectangle(cornerRadius: 14))
}
//
private func materialRow(_ material: ScenicPromotionManagedMaterial) -> some View {
HStack(spacing: 10) {
Button {
previewMaterial = material
} label: {
HStack(spacing: 12) {
materialThumbnail(material)
VStack(alignment: .leading, spacing: 5) {
Text(material.title)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
Text(material.subtitle)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(1)
Text(material.createdAt)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(hex: 0x8A95A6))
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(Color(hex: 0x9AA5B5))
}
}
.buttonStyle(.plain)
Button {
viewModel.deleteMaterial(material)
} label: {
Image(systemName: "trash")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(AppDesign.danger)
.frame(width: 36, height: 36)
.background(Color(hex: 0xFFF1F1), in: Circle())
}
.buttonStyle(.plain)
}
.padding(10)
.background(Color(hex: 0xF8FAFD), in: RoundedRectangle(cornerRadius: 14))
}
//
private func materialThumbnail(_ material: ScenicPromotionManagedMaterial) -> some View {
ZStack {
Image(material.assetName)
.resizable()
.scaledToFill()
.frame(width: 64, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 10))
if material.kind == .video {
Image(systemName: "play.fill")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white)
.frame(width: 26, height: 26)
.background(.black.opacity(0.42), in: Circle())
}
}
}
// Loading
private var settingsLoadingView: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
RoundedRectangle(cornerRadius: 18)
.fill(.white)
.frame(height: 92)
ForEach(0..<4, id: \.self) { _ in
RoundedRectangle(cornerRadius: 18)
.fill(.white)
.frame(height: 132)
}
}
.redacted(reason: .placeholder)
.padding(16)
}
}
// Empty / Error
private func settingsFullStateView(
icon: String,
title: String,
message: String,
buttonTitle: String,
action: @escaping () -> Void
) -> some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: icon)
.font(.system(size: 42, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 82, height: 82)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 22))
VStack(spacing: 7) {
Text(title)
.font(.system(size: 20, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
Text(message)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
}
Button(action: action) {
Text(buttonTitle)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
.frame(width: 132, height: 42)
.background(AppDesign.primary, in: Capsule())
}
.buttonStyle(.plain)
Spacer()
}
.padding(28)
}
//
private var submitBar: some View {
VStack(spacing: 0) {
Divider()
Button {
handleSubmit()
} label: {
Text("提交设置")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 10)
}
.background(.ultraThinMaterial)
}
}
// MARK: -
struct ScenicPromotionMaterialPreviewView: View {
// Mock
let material: ScenicPromotionManagedMaterial
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ZStack {
Image(material.assetName)
.resizable()
.scaledToFill()
.frame(height: 280)
.frame(maxWidth: .infinity)
.clipped()
if material.kind == .video {
Image(systemName: "play.fill")
.font(.system(size: 34, weight: .bold))
.foregroundStyle(.white)
.frame(width: 76, height: 76)
.background(.black.opacity(0.36), in: Circle())
.overlay(Circle().stroke(.white, lineWidth: 3))
}
}
.clipShape(RoundedRectangle(cornerRadius: 18))
VStack(alignment: .leading, spacing: 10) {
Text(material.title)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
Text(material.subtitle)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
Divider()
previewInfoRow(title: "素材类型", value: material.kind.shortTitle)
previewInfoRow(title: "上传时间", value: material.createdAt)
if let duration = material.duration {
previewInfoRow(title: "视频时长", value: duration)
}
}
.padding(16)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
}
.padding(16)
}
.background(AppDesign.background.ignoresSafeArea())
.navigationTitle(material.kind == .video ? "视频预览" : "图片预览")
.navigationBarTitleDisplayMode(.inline)
}
//
private func previewInfoRow(title: String, value: String) -> some View {
HStack {
Text(title)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Text(value)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
}
}
}

View File

@ -0,0 +1,789 @@
//
// ScenicPromotionViews.swift
// suixinkan
//
// Created by Codex on 2026/7/2.
//
import SwiftUI
// MARK: -
private enum ScenicPromotionLayout {
// 20px
static let screenEdgePadding: CGFloat = 20
// 20px
static let moduleVerticalSpacing: CGFloat = 12
// 5
static let visibleSpotTabCount: CGFloat = 5
}
// MARK: -
private struct ScenicPromotionSettingsRoute: Identifiable, Hashable {
let id = "promotion_page_settings"
}
// MARK: -
struct ScenicPromotionView: View {
// ViewModelView
@StateObject private var viewModel = ScenicPromotionViewModel()
// 使
@State private var settingsRoute: ScenicPromotionSettingsRoute?
//
@State private var qrTapCount = 0
@State private var qrTapResetTask: Task<Void, Never>?
//
@State private var showPasswordPrompt = false
@State private var settingsPassword = ""
@State private var showPasswordError = false
//
@State private var showDemoStateMenu = false
//
@State private var showPlayToast = false
var body: some View {
ZStack {
ScenicPromotionSkyBackground()
.ignoresSafeArea()
Group {
switch viewModel.pageState {
case .idle, .loading:
loadingView
.transition(.opacity.combined(with: .scale(scale: 0.98)))
case .empty:
fullStateView(
icon: "doc.text.image",
title: "暂无宣传页数据",
message: "当前景区还没有配置排队导览宣传内容",
buttonTitle: "重新加载"
) {
Task { await viewModel.load() }
}
.transition(.opacity.combined(with: .move(edge: .bottom)))
case .error(let message):
fullStateView(
icon: "wifi.exclamationmark",
title: "加载失败",
message: message,
buttonTitle: "重试"
) {
Task { await viewModel.load() }
}
.transition(.opacity.combined(with: .move(edge: .bottom)))
case .normal:
contentView
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.animation(.easeInOut(duration: 0.28), value: viewModel.pageState)
}
.ignoresSafeArea(.container, edges: .top)
.toolbar(.hidden, for: .navigationBar)
.appNavigationDestination(item: $settingsRoute) { _ in
ScenicPromotionSettingsView()
}
.alert("输入管理密码", isPresented: $showPasswordPrompt) {
SecureField("请输入密码", text: $settingsPassword)
Button("取消", role: .cancel) {
settingsPassword = ""
}
Button("进入") {
verifySettingsPassword()
}
} message: {
Text("连续点击二维码 3 次后可进入宣传页设置")
}
.alert("密码错误", isPresented: $showPasswordError) {
Button("知道了", role: .cancel) {}
} message: {
Text("请输入正确密码")
}
.alert("视频播放", isPresented: $showPlayToast) {
Button("知道了", role: .cancel) {}
} message: {
Text("Demo 已模拟播放入口,后续接入真实视频地址即可播放。")
}
.confirmationDialog("演示状态", isPresented: $showDemoStateMenu, titleVisibility: .visible) {
ForEach(ScenicPromotionDemoScenario.allCases) { scenario in
Button(scenario.title) {
viewModel.applyScenario(scenario)
}
}
Button("取消", role: .cancel) {}
}
.onDisappear {
qrTapResetTask?.cancel()
}
.task {
await viewModel.loadIfNeeded()
}
}
//
private var contentView: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: ScenicPromotionLayout.moduleVerticalSpacing) {
if let board = viewModel.board {
header(board)
spotTabs(board.spots)
metricsGrid
queuePanel
mediaToolbar
mediaStrip
heroMedia
}
}
.padding(.horizontal, ScenicPromotionLayout.screenEdgePadding)
.padding(.top, 28)
.padding(.bottom, 24)
}
}
//
private func header(_ board: ScenicPromotionBoard) -> some View {
ZStack(alignment: .topTrailing) {
VStack(spacing: 7) {
Text("\(board.scenicName) · 排队导览")
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(Color(hex: 0x0E3F91))
.multilineTextAlignment(.center)
.minimumScaleFactor(0.72)
.lineLimit(1)
.shadow(color: .white.opacity(0.55), radius: 0, x: 0, y: 2)
HStack(spacing: 12) {
Rectangle()
.fill(Color(hex: 0x2F80ED, alpha: 0.35))
.frame(width: 36, height: 1)
Text(board.subtitle)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Color(hex: 0x1D5DA8))
Rectangle()
.fill(Color(hex: 0x2F80ED, alpha: 0.35))
.frame(width: 36, height: 1)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
.padding(.horizontal, 62)
Button {
handleQRCodeTap()
} label: {
ScenicPromotionQRCodeView()
.frame(width: 56, height: 56)
.padding(5)
.background(.white, in: RoundedRectangle(cornerRadius: 9))
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.16), radius: 10, y: 4)
}
.buttonStyle(.plain)
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.7)
.onEnded { _ in
showDemoStateMenu = true
}
)
.accessibilityLabel("二维码设置入口")
}
.frame(height: 96)
}
// 3
private func handleQRCodeTap() {
qrTapCount += 1
qrTapResetTask?.cancel()
if qrTapCount >= 3 {
qrTapCount = 0
settingsPassword = ""
showPasswordPrompt = true
return
}
qrTapResetTask = Task {
try? await Task.sleep(nanoseconds: 1_100_000_000)
guard !Task.isCancelled else { return }
await MainActor.run {
qrTapCount = 0
}
}
}
// Mock 1
private func verifySettingsPassword() {
if settingsPassword == "1" {
settingsPassword = ""
settingsRoute = ScenicPromotionSettingsRoute()
} else {
settingsPassword = ""
showPasswordError = true
}
}
// 5 5 5
@ViewBuilder
private func spotTabs(_ spots: [ScenicPromotionSpot]) -> some View {
if spots.isEmpty {
EmptyView()
} else {
GeometryReader { proxy in
let visibleCount = min(CGFloat(max(spots.count, 1)), ScenicPromotionLayout.visibleSpotTabCount)
let tabWidth = proxy.size.width / visibleCount
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(spots) { spot in
spotTabButton(spot)
.frame(width: tabWidth)
}
}
}
.scrollDisabled(spots.count <= Int(ScenicPromotionLayout.visibleSpotTabCount))
}
.frame(height: 42)
.background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14))
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(.white.opacity(0.8), lineWidth: 1)
)
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.12), radius: 12, y: 5)
}
}
// 5
private func spotTabButton(_ spot: ScenicPromotionSpot) -> some View {
let isSelected = viewModel.selectedSpot?.id == spot.id
return Button {
viewModel.selectSpot(spot)
} label: {
HStack(spacing: 3) {
Image(systemName: spot.iconName)
.font(.system(size: 13, weight: .bold))
Text(spot.name)
.font(.system(size: 12, weight: .bold))
.lineLimit(1)
.minimumScaleFactor(0.66)
}
.foregroundStyle(isSelected ? .white : Color(hex: 0x14213D))
.frame(maxWidth: .infinity)
.frame(height: 42)
.padding(.horizontal, 4)
.background(
Group {
if isSelected {
LinearGradient(
colors: [Color(hex: 0x1286FF), Color(hex: 0x006BFF)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
Color.clear
}
}
)
}
.buttonStyle(.plain)
}
//
private var metricsGrid: some View {
let queue = viewModel.queue
let metrics: [(String, String, String, Color)] = [
("当前排队人数", queue.map { "\($0.waitingPeople)" } ?? "--", "", Color(hex: 0x1580FF)),
("预计等待时间", queue.map { "\($0.estimatedMinutes)" } ?? "--", "分钟", Color(hex: 0xF28C18)),
("平均等待时长", queue.map { "\($0.averageMinutes)" } ?? "--", "分钟", Color(hex: 0x2D8E43))
]
return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) {
ForEach(metrics, id: \.0) { metric in
VStack(spacing: 8) {
Text(metric.0)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color(hex: 0x113B79))
.lineLimit(1)
.minimumScaleFactor(0.76)
HStack(alignment: .lastTextBaseline, spacing: 2) {
Text(metric.1)
.font(.system(size: 34, weight: .heavy))
.foregroundStyle(metric.3)
.minimumScaleFactor(0.55)
if metric.1 != "--" {
Text(metric.2)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(metric.3)
}
}
if metric.1 == "--" {
Text("暂无数据")
.font(.system(size: 11))
.foregroundStyle(Color(hex: 0x5272A5))
}
}
.padding(.horizontal, 6)
.frame(maxWidth: .infinity)
.frame(height: 92)
.background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(.white.opacity(0.85), lineWidth: 1)
)
}
}
}
//
private var queuePanel: some View {
Group {
if let queue = viewModel.queue {
HStack(spacing: 10) {
queueSide(icon: "camera.aperture", title: "下一位", value: queue.nextNumber, tint: Color(hex: 0x197BFF))
Divider()
.frame(height: 52)
VStack(spacing: 0) {
Text("当前叫号")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(Color(hex: 0x123F86))
Text(queue.currentNumber)
.font(.system(size: 38, weight: .heavy))
.foregroundStyle(Color(hex: 0x197BFF))
.minimumScaleFactor(0.55)
}
.frame(maxWidth: .infinity)
Divider()
.frame(height: 52)
queueSide(icon: "person.2.fill", title: "拍摄用时", value: queue.shootingDuration, tint: Color(hex: 0x7A4DFF))
}
.padding(.horizontal, 14)
.padding(.vertical, 7)
} else {
HStack(spacing: 18) {
Image(systemName: "list.clipboard")
.font(.system(size: 46, weight: .semibold))
.foregroundStyle(Color(hex: 0x2F80ED))
.frame(width: 92, height: 92)
.background(Color(hex: 0xEAF4FF), in: RoundedRectangle(cornerRadius: 18))
VStack(alignment: .leading, spacing: 8) {
Text("当前暂无排队信息")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(Color(hex: 0x123F86))
Text("可先浏览景点样片,稍后再查看排队动态")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Color(hex: 0x5272A5))
}
Spacer(minLength: 0)
}
.padding(18)
}
}
.frame(maxWidth: .infinity)
.background(.white.opacity(0.86), in: RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(.white.opacity(0.9), lineWidth: 1)
)
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.10), radius: 14, y: 6)
}
//
private func queueSide(icon: String, title: String, value: String, tint: Color) -> some View {
HStack(spacing: 7) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 34, height: 34)
.background(tint.opacity(0.13), in: Circle())
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(hex: 0x113B79))
Text(value)
.font(.system(size: 13, weight: .heavy))
.foregroundStyle(tint)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.minimumScaleFactor(0.76)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// /
private var mediaToolbar: some View {
HStack(spacing: 14) {
HStack(spacing: 0) {
ForEach(ScenicPromotionMediaMode.allCases) { mode in
Button {
viewModel.selectMode(mode)
} label: {
HStack(spacing: 6) {
Image(systemName: mode.iconName)
Text(mode.title)
}
.font(.system(size: 14, weight: .bold))
.foregroundStyle(viewModel.selectedMode == mode ? .white : Color(hex: 0x1B4D91))
.frame(width: 68, height: 34)
.background(
viewModel.selectedMode == mode
? Color(hex: 0x0B79FF)
: Color.clear
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(.white.opacity(0.78), in: Capsule())
.overlay(Capsule().stroke(.white.opacity(0.9), lineWidth: 1))
Spacer()
Label("触屏切换视频/照片,左右滑动查看更多", systemImage: "hand.tap.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color(hex: 0x124E97))
.lineLimit(1)
.minimumScaleFactor(0.58)
}
}
//
private var mediaStrip: some View {
HStack(spacing: 8) {
Button {
viewModel.moveMedia(-1)
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(Color(hex: 0x326EAE))
.frame(width: 28, height: 100)
}
.buttonStyle(.plain)
if viewModel.mediaItems.isEmpty {
mediaEmptyPanel
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(viewModel.mediaItems) { media in
Button {
viewModel.selectMedia(media)
} label: {
ScenicPromotionMediaArtwork(media: media, compact: true, useHeroAsset: false)
.frame(width: media.previewWidth, height: 66)
.overlay(alignment: .bottomTrailing) {
if let duration = media.duration {
Text(duration)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.black.opacity(0.42), in: Capsule())
.padding(6)
}
}
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(
viewModel.selectedMedia?.id == media.id ? Color(hex: 0x0B79FF) : .white.opacity(0.75),
lineWidth: viewModel.selectedMedia?.id == media.id ? 3 : 1
)
)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 2)
}
}
Button {
viewModel.moveMedia(1)
} label: {
Image(systemName: "chevron.right")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(Color(hex: 0x326EAE))
.frame(width: 28, height: 100)
}
.buttonStyle(.plain)
}
.padding(10)
.background(.white.opacity(0.72), in: RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(.white.opacity(0.85), lineWidth: 1)
)
}
// /
private var mediaEmptyPanel: some View {
VStack(spacing: 12) {
Image(systemName: viewModel.selectedMode == .video ? "play.rectangle.fill" : "photo.on.rectangle.angled")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(Color(hex: 0x57A2FF))
Text(viewModel.selectedMode == .video ? "暂无视频" : "暂无照片")
.font(.system(size: 19, weight: .bold))
.foregroundStyle(Color(hex: 0x1E5DA8))
Text("敬请期待更多精彩内容")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Color(hex: 0x6B83A8))
}
.frame(maxWidth: .infinity, minHeight: 172)
}
//
private var heroMedia: some View {
Group {
if let media = viewModel.selectedMedia {
Button {
if media.mode == .video {
showPlayToast = true
}
} label: {
ZStack(alignment: .bottomLeading) {
ScenicPromotionMediaArtwork(media: media, compact: false, useHeroAsset: true)
.frame(height: 220)
if media.heroAssetName == nil {
LinearGradient(
colors: [.clear, .black.opacity(0.56)],
startPoint: .center,
endPoint: .bottom
)
VStack(alignment: .leading, spacing: 8) {
if media.mode == .video {
Image(systemName: "play.fill")
.font(.system(size: 30, weight: .bold))
.foregroundStyle(.white)
.frame(width: 64, height: 64)
.background(.white.opacity(0.2), in: Circle())
.overlay(Circle().stroke(.white, lineWidth: 3))
.frame(maxWidth: .infinity, alignment: .center)
}
Label(media.title, systemImage: "mappin.circle.fill")
.font(.system(size: 20, weight: .heavy))
.foregroundStyle(.white)
Text(media.subtitle)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.92))
}
.padding(18)
}
}
.clipShape(RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(.white.opacity(0.86), lineWidth: 1)
)
}
.buttonStyle(.plain)
} else {
mediaEmptyPanel
.frame(height: 220)
.frame(maxWidth: .infinity)
.background(.white.opacity(0.7), in: RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(.white.opacity(0.85), lineWidth: 1)
)
}
}
}
// Loading
private var loadingView: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 18) {
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.72))
.frame(height: 110)
RoundedRectangle(cornerRadius: 16)
.fill(.white.opacity(0.72))
.frame(height: 50)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) {
ForEach(0..<3, id: \.self) { _ in
RoundedRectangle(cornerRadius: 14)
.fill(.white.opacity(0.72))
.frame(height: 92)
}
}
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.72))
.frame(height: 96)
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.72))
.frame(height: 112)
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.72))
.frame(height: 220)
}
.redacted(reason: .placeholder)
.padding(ScenicPromotionLayout.screenEdgePadding)
}
}
// Empty / Error
private func fullStateView(
icon: String,
title: String,
message: String,
buttonTitle: String,
action: @escaping () -> Void
) -> some View {
VStack(spacing: 18) {
Spacer()
Image(systemName: icon)
.font(.system(size: 48, weight: .semibold))
.foregroundStyle(Color(hex: 0x0B79FF))
.frame(width: 92, height: 92)
.background(.white.opacity(0.75), in: RoundedRectangle(cornerRadius: 24))
VStack(spacing: 8) {
Text(title)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Color(hex: 0x143D78))
Text(message)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Color(hex: 0x5F789E))
.multilineTextAlignment(.center)
}
Button(action: action) {
Text(buttonTitle)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
.frame(width: 150, height: 46)
.background(Color(hex: 0x0B79FF), in: Capsule())
}
.buttonStyle(.plain)
Spacer()
}
.padding(28)
}
}
// MARK: -
private struct ScenicPromotionMediaArtwork: View {
let media: ScenicPromotionMedia
let compact: Bool
let useHeroAsset: Bool
var body: some View {
GeometryReader { proxy in
if let assetName = useHeroAsset ? (media.heroAssetName ?? media.assetName) : media.assetName {
Image(assetName)
.resizable()
.scaledToFill()
.frame(width: proxy.size.width, height: proxy.size.height)
.clipped()
} else {
ZStack {
LinearGradient(
colors: media.palette.map { Color(hex: $0) },
startPoint: .topLeading,
endPoint: .bottomTrailing
)
ScenicPromotionMountainLayer()
.fill(.white.opacity(0.28))
.frame(height: proxy.size.height * 0.45)
.offset(y: proxy.size.height * 0.02)
ScenicPromotionMountainLayer()
.fill(Color(hex: 0x0F5B85, alpha: 0.22))
.frame(height: proxy.size.height * 0.38)
.offset(y: proxy.size.height * 0.13)
VStack {
Spacer()
Rectangle()
.fill(Color(hex: 0x2D9D4E, alpha: 0.52))
.frame(height: proxy.size.height * 0.2)
}
if !compact {
Image(systemName: "figure.walk")
.font(.system(size: 74, weight: .bold))
.foregroundStyle(.white.opacity(0.82))
.offset(x: proxy.size.width * 0.23, y: proxy.size.height * 0.1)
}
}
}
}
.clipShape(RoundedRectangle(cornerRadius: compact ? 12 : 18))
}
}
// 使
private struct ScenicPromotionMountainLayer: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.width * 0.16, y: rect.height * 0.42))
path.addLine(to: CGPoint(x: rect.width * 0.28, y: rect.height * 0.68))
path.addLine(to: CGPoint(x: rect.width * 0.42, y: rect.height * 0.25))
path.addLine(to: CGPoint(x: rect.width * 0.58, y: rect.height * 0.6))
path.addLine(to: CGPoint(x: rect.width * 0.76, y: rect.height * 0.18))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.height * 0.56))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.closeSubpath()
return path
}
}
// 绿
private struct ScenicPromotionSkyBackground: View {
var body: some View {
ZStack(alignment: .bottom) {
Image("ScenicPromotionBackground")
.resizable()
.scaledToFill()
.overlay(
LinearGradient(
colors: [
Color(hex: 0x0E88FF, alpha: 0.12),
Color(hex: 0xE9F8FF, alpha: 0.5),
Color(hex: 0x82D860, alpha: 0.16)
],
startPoint: .top,
endPoint: .bottom
)
)
LinearGradient(
colors: [
Color(hex: 0x1593FF, alpha: 0.16),
Color(hex: 0xBFE9FF, alpha: 0.10),
Color(hex: 0xEAF8FF, alpha: 0.42)
],
startPoint: .top,
endPoint: .bottom
)
LinearGradient(
colors: [Color.clear, Color(hex: 0x62BA4C, alpha: 0.18)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 260)
}
}
}
//
private struct ScenicPromotionQRCodeView: View {
var body: some View {
Image("ScenicPromotionQRCode")
.resizable()
.scaledToFit()
}
}