diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 45a585d..f20a244 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,15 +64,11 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanUITests; sourceTree = ""; }; @@ -328,10 +324,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/Contents.json new file mode 100644 index 0000000..1ba4f8e --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "scenic_promotion_background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/scenic_promotion_background.png b/suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/scenic_promotion_background.png new file mode 100644 index 0000000..64b084d Binary files /dev/null and b/suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/scenic_promotion_background.png differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/Contents.json new file mode 100644 index 0000000..16bc84d --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "scenic_promotion_hero.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/scenic_promotion_hero.png b/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/scenic_promotion_hero.png new file mode 100644 index 0000000..79f3447 Binary files /dev/null and b/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/scenic_promotion_hero.png differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/Contents.json new file mode 100644 index 0000000..14e5e69 --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "scenic_promotion_qr.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/scenic_promotion_qr.png b/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/scenic_promotion_qr.png new file mode 100644 index 0000000..fefbf5b Binary files /dev/null and b/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/scenic_promotion_qr.png differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/Contents.json new file mode 100644 index 0000000..e54e3e5 --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "scenic_promotion_thumb_grass.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/scenic_promotion_thumb_grass.png b/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/scenic_promotion_thumb_grass.png new file mode 100644 index 0000000..daa579c Binary files /dev/null and b/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/scenic_promotion_thumb_grass.png differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/Contents.json new file mode 100644 index 0000000..26433fa --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "scenic_promotion_thumb_horse.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/scenic_promotion_thumb_horse.png b/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/scenic_promotion_thumb_horse.png new file mode 100644 index 0000000..0b20d3e Binary files /dev/null and b/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/scenic_promotion_thumb_horse.png differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/Contents.json new file mode 100644 index 0000000..a4d56ec --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "scenic_promotion_thumb_marker.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/scenic_promotion_thumb_marker.png b/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/scenic_promotion_thumb_marker.png new file mode 100644 index 0000000..ed71e97 Binary files /dev/null and b/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/scenic_promotion_thumb_marker.png differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/Contents.json new file mode 100644 index 0000000..032ced8 --- /dev/null +++ b/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "scenic_promotion_thumb_portrait.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/scenic_promotion_thumb_portrait.png b/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/scenic_promotion_thumb_portrait.png new file mode 100644 index 0000000..e284525 Binary files /dev/null and b/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/scenic_promotion_thumb_portrait.png differ diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index 58d1c25..b938093 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -63,3 +63,5 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 `report_photographer` 已由 `Features/WildPhotographerReport` 接管。 + +`promotion_page` 已由 `Features/ScenicPromotion` 接管。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index d6be001..a5cfdbd 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -68,6 +68,7 @@ enum HomeRoute: Hashable { case travelAlbumEntry case operatingArea case pilotCertification + case scenicPromotion case modulePlaceholder(uri: String, title: String) } diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 62232a4..81e6799 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -47,6 +47,7 @@ enum HomeMenuRouter { "project_edit": "项目管理", "location_report": "位置上报", "report_photographer": "举报摄影师", + "promotion_page": "宣传页", "registration_invitation": "注册邀请", "store": "店铺管理", "fly": "飞行管理", @@ -134,6 +135,8 @@ enum HomeMenuRouter { return .destination(.locationReportHistory) case "report_photographer": return .destination(.photographerReport) + case "promotion_page": + return .destination(.scenicPromotion) case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info": return .destination(.depositOrders) case "withdrawal_audit": @@ -220,6 +223,7 @@ enum HomeMenuRouter { "project_edit", "location_report", "report_photographer", + "promotion_page", "registration_invitation", "photographer_invite", "invite_record", @@ -300,6 +304,8 @@ enum HomeMenuRouter { return "位置上报" case "report_photographer": return "举报摄影师" + case "promotion_page": + return "宣传页" case "pm", "project_edit": return "项目管理" case "pm_manager": diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index c16d673..2a2f2a0 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -31,6 +31,7 @@ struct HomeCommonMenuStore { "pm", "location_report", "report_photographer", + "promotion_page", "registration_invitation", "store", "fly", diff --git a/suixinkan/Features/Home/Views/HomeIconCatalog.swift b/suixinkan/Features/Home/Views/HomeIconCatalog.swift index 3faa94f..1489092 100644 --- a/suixinkan/Features/Home/Views/HomeIconCatalog.swift +++ b/suixinkan/Features/Home/Views/HomeIconCatalog.swift @@ -56,6 +56,8 @@ enum HomeIconCatalog { "mappin.circle.fill" case "report_photographer": "shield.fill" + case "promotion_page": + "rectangle.on.rectangle.angled" case "registration_invitation", "photographer_invite": "envelope.open.fill" case "store": diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 3b1e397..f2c3d99 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -112,6 +112,8 @@ extension HomeRoute { OperatingAreaView() case .pilotCertification: PilotCertificationView() + case .scenicPromotion: + ScenicPromotionView() case let .modulePlaceholder(uri, title): HomeMigrationModuleView(title: title, uri: uri) } diff --git a/suixinkan/Features/ScenicPromotion/Models/ScenicPromotionModels.swift b/suixinkan/Features/ScenicPromotion/Models/ScenicPromotionModels.swift new file mode 100644 index 0000000..6221076 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Models/ScenicPromotionModels.swift @@ -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 +} diff --git a/suixinkan/Features/ScenicPromotion/ScenicPromotion.md b/suixinkan/Features/ScenicPromotion/ScenicPromotion.md new file mode 100644 index 0000000..6f2d826 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/ScenicPromotion.md @@ -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` — 设置页与素材预览 diff --git a/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift b/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift new file mode 100644 index 0000000..2d83030 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift @@ -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] + } + } +} diff --git a/suixinkan/Features/ScenicPromotion/ViewModels/ScenicPromotionViewModels.swift b/suixinkan/Features/ScenicPromotion/ViewModels/ScenicPromotionViewModels.swift new file mode 100644 index 0000000..20523ed --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/ViewModels/ScenicPromotionViewModels.swift @@ -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 = [] + @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 + } +} diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsViews.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsViews.swift new file mode 100644 index 0000000..2c0182f --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsViews.swift @@ -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( + 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) + } + } +} diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionViews.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionViews.swift new file mode 100644 index 0000000..f394746 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionViews.swift @@ -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 { + // 页面主数据和交互状态都收敛到 ViewModel,View 只负责布局和事件转发。 + @StateObject private var viewModel = ScenicPromotionViewModel() + // 二维码三连击后进入设置页,使用轻量本地路由控制跳转。 + @State private var settingsRoute: ScenicPromotionSettingsRoute? + // 记录二维码短时间点击次数,用于触发隐藏密码入口。 + @State private var qrTapCount = 0 + @State private var qrTapResetTask: Task? + // 设置入口密码弹窗状态。 + @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() + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 0874b3b..9196ae2 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -48,6 +48,7 @@ final class HomeMenuRouterTests: XCTestCase { ("location_report", .destination(.locationReport)), ("location_report_history", .destination(.locationReportHistory)), ("report_photographer", .destination(.photographerReport)), + ("promotion_page", .destination(.scenicPromotion)), ("pm", .destination(.projectManagement)), ("project_edit", .destination(.projectManagement)), ("pm_manager", .destination(.pmProjectManagement)), diff --git a/suixinkanTests/ScenicPromotion/ScenicPromotionTests.swift b/suixinkanTests/ScenicPromotion/ScenicPromotionTests.swift new file mode 100644 index 0000000..b9d5f81 --- /dev/null +++ b/suixinkanTests/ScenicPromotion/ScenicPromotionTests.swift @@ -0,0 +1,183 @@ +// +// ScenicPromotionTests.swift +// suixinkanTests +// +// Created by Codex on 2026/7/2. +// + +import XCTest +@testable import suixinkan + +/// 宣传页主页面 ViewModel 测试,覆盖加载、场景切换与素材选中逻辑。 +@MainActor +final class ScenicPromotionViewModelTests: XCTestCase { + /// 测试正常场景加载后进入 normal 状态并选中首个打卡点与素材。 + func testApplyNormalScenarioLoadsBoardAndSelection() { + let viewModel = ScenicPromotionViewModel() + viewModel.applyScenario(.normal) + + XCTAssertEqual(viewModel.pageState, .normal) + XCTAssertNotNil(viewModel.board) + XCTAssertEqual(viewModel.board?.scenicName, "那拉提景区") + XCTAssertNotNil(viewModel.selectedSpot) + XCTAssertNotNil(viewModel.selectedMedia) + XCTAssertFalse(viewModel.mediaItems.isEmpty) + } + + /// 测试空数据场景清空看板并进入 empty 状态。 + func testApplyEmptyScenarioClearsBoard() { + let viewModel = ScenicPromotionViewModel() + viewModel.applyScenario(.normal) + viewModel.applyScenario(.empty) + + XCTAssertEqual(viewModel.pageState, .empty) + XCTAssertNil(viewModel.board) + } + + /// 测试异常场景进入 error 状态。 + func testApplyErrorScenarioSetsErrorState() { + let viewModel = ScenicPromotionViewModel() + viewModel.applyScenario(.error) + + if case .error = viewModel.pageState { + XCTAssertTrue(true) + } else { + XCTFail("Expected error page state") + } + } + + /// 测试无媒体场景清空素材列表。 + func testNoMediaScenarioHidesMediaItems() { + let viewModel = ScenicPromotionViewModel() + viewModel.applyScenario(.normal) + viewModel.applyScenario(.noMedia) + + XCTAssertTrue(viewModel.mediaItems.isEmpty) + XCTAssertNil(viewModel.selectedMedia) + } + + /// 测试无排队场景隐藏排队信息。 + func testNoQueueScenarioHidesQueueInfo() { + let viewModel = ScenicPromotionViewModel() + viewModel.applyScenario(.normal) + viewModel.applyScenario(.noQueue) + + XCTAssertNil(viewModel.queue) + XCTAssertFalse(viewModel.hasQueueData) + } + + /// 测试切换打卡点与素材模式会更新选中素材。 + func testSelectSpotAndModeUpdatesSelectedMedia() { + let viewModel = ScenicPromotionViewModel() + viewModel.applyScenario(.normal) + + guard let board = viewModel.board, board.spots.count > 1 else { + XCTFail("Expected multiple spots in mock board") + return + } + + let secondSpot = board.spots[1] + viewModel.selectSpot(secondSpot) + XCTAssertEqual(viewModel.selectedSpot?.id, secondSpot.id) + + viewModel.selectMode(.photo) + XCTAssertEqual(viewModel.selectedMode, .photo) + XCTAssertEqual(viewModel.selectedMedia?.mode, .photo) + } + + /// 测试左右切换素材循环选中。 + func testMoveMediaCyclesSelection() { + let viewModel = ScenicPromotionViewModel() + viewModel.applyScenario(.normal) + + guard viewModel.mediaItems.count > 1 else { + XCTFail("Expected multiple media items") + return + } + + let firstID = viewModel.selectedMedia?.id + viewModel.moveMedia(1) + XCTAssertNotEqual(viewModel.selectedMedia?.id, firstID) + + viewModel.moveMedia(-1) + XCTAssertEqual(viewModel.selectedMedia?.id, firstID) + } +} + +/// 宣传页设置 ViewModel 测试,覆盖上传校验与提交规则。 +@MainActor +final class ScenicPromotionSettingsViewModelTests: XCTestCase { + /// 测试正常场景加载 Mock 配置数据。 + func testApplyNormalScenarioLoadsSettingsData() { + let viewModel = ScenicPromotionSettingsViewModel() + viewModel.applyScenario(.normal) + + XCTAssertEqual(viewModel.pageState, .normal) + XCTAssertFalse(viewModel.queuePoints.isEmpty) + XCTAssertFalse(viewModel.materialPoints.isEmpty) + XCTAssertFalse(viewModel.selectedQueuePointIDs.isEmpty) + } + + /// 测试未选择素材点时上传会被拦截。 + func testAddMaterialFailsWithoutMaterialPoint() { + let viewModel = ScenicPromotionSettingsViewModel() + viewModel.applyScenario(.normal) + viewModel.selectedMaterialPointID = nil + + XCTAssertFalse(viewModel.addMaterial(kind: .video)) + } + + /// 测试选择素材点后可以 Mock 上传素材。 + func testAddMaterialSucceedsWithMaterialPoint() { + let viewModel = ScenicPromotionSettingsViewModel() + viewModel.applyScenario(.normal) + + guard let point = viewModel.materialPoints.first else { + XCTFail("Expected material points") + return + } + + viewModel.selectMaterialPoint(point) + let beforeCount = viewModel.materials(for: .video).count + XCTAssertTrue(viewModel.addMaterial(kind: .video)) + XCTAssertEqual(viewModel.materials(for: .video).count, beforeCount + 1) + } + + /// 测试删除素材会从当前点位列表移除。 + func testDeleteMaterialRemovesItem() { + let viewModel = ScenicPromotionSettingsViewModel() + viewModel.applyScenario(.normal) + + guard let point = viewModel.materialPoints.first else { + XCTFail("Expected material points") + return + } + + viewModel.selectMaterialPoint(point) + guard viewModel.addMaterial(kind: .image), + let material = viewModel.materials(for: .image).last else { + XCTFail("Expected uploaded material") + return + } + + viewModel.deleteMaterial(material) + XCTAssertFalse(viewModel.materials(for: .image).contains(where: { $0.id == material.id })) + } + + /// 测试未绑定排队点时提交失败。 + func testSubmitFailsWithoutQueuePoints() { + let viewModel = ScenicPromotionSettingsViewModel() + viewModel.applyScenario(.normal) + viewModel.selectedQueuePointIDs = [] + + XCTAssertFalse(viewModel.submit()) + } + + /// 测试绑定排队点后提交成功。 + func testSubmitSucceedsWithQueuePoints() { + let viewModel = ScenicPromotionSettingsViewModel() + viewModel.applyScenario(.normal) + + XCTAssertTrue(viewModel.submit()) + } +} diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 0fb273a..b4f139f 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -112,6 +112,7 @@ | 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 | | 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | | 完成 | 举报摄影师 | `photographer_report` 已接真实页面;支持提交举报、我的举报、详情、补充证据、附近风险地图;当前为 Mock 数据闭环。 | 后续接入真实 API 时保持页面字段和交互不变。 | +| 完成 | 宣传页 | `scenic_promotion` 已接 Mock 演示页;支持打卡点切换、排队导览、视频/照片素材、隐藏设置入口。 | 后续接入真实 API 时替换 Mock 数据来源。 | | 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | | 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 | | 完成 | 合作订单 | `cooperation_order` 已接真实页面;双 Tab 列表(获客线索/获客订单)、合作获客员、扫码绑定、主 Tab 扫码识别 sale_user_bind。 | 与订单模块 orderType 11/14 订单来源联动。 |