diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 2252dc6..0644cbd 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,11 +64,15 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanUITests; sourceTree = ""; }; @@ -324,14 +328,10 @@ 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 deleted file mode 100644 index 1ba4f8e..0000000 --- a/suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 64b084d..0000000 Binary files a/suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/scenic_promotion_background.png and /dev/null differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/Contents.json deleted file mode 100644 index 16bc84d..0000000 --- a/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 79f3447..0000000 Binary files a/suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/scenic_promotion_hero.png and /dev/null differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/Contents.json deleted file mode 100644 index 14e5e69..0000000 --- a/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index fefbf5b..0000000 Binary files a/suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/scenic_promotion_qr.png and /dev/null differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/Contents.json deleted file mode 100644 index e54e3e5..0000000 --- a/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index daa579c..0000000 Binary files a/suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/scenic_promotion_thumb_grass.png and /dev/null differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/Contents.json deleted file mode 100644 index 26433fa..0000000 --- a/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index 0b20d3e..0000000 Binary files a/suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/scenic_promotion_thumb_horse.png and /dev/null differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/Contents.json deleted file mode 100644 index a4d56ec..0000000 --- a/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index ed71e97..0000000 Binary files a/suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/scenic_promotion_thumb_marker.png and /dev/null differ diff --git a/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/Contents.json deleted file mode 100644 index 032ced8..0000000 --- a/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "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 deleted file mode 100644 index e284525..0000000 Binary files a/suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/scenic_promotion_thumb_portrait.png and /dev/null differ diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index b938093..cf37bcc 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -64,4 +64,4 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 `report_photographer` 已由 `Features/WildPhotographerReport` 接管。 -`promotion_page` 已由 `Features/ScenicPromotion` 接管。 +`promotion_page`(宣传页)已从 iOS 移除;该 URI 列入 `HomeCommonMenuStore.hiddenHomeMenuURIs`,首页与全部功能页均不展示。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index a5cfdbd..d6be001 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -68,7 +68,6 @@ 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 81e6799..62232a4 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -47,7 +47,6 @@ enum HomeMenuRouter { "project_edit": "项目管理", "location_report": "位置上报", "report_photographer": "举报摄影师", - "promotion_page": "宣传页", "registration_invitation": "注册邀请", "store": "店铺管理", "fly": "飞行管理", @@ -135,8 +134,6 @@ 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": @@ -223,7 +220,6 @@ enum HomeMenuRouter { "project_edit", "location_report", "report_photographer", - "promotion_page", "registration_invitation", "photographer_invite", "invite_record", @@ -304,8 +300,6 @@ 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 2a2f2a0..36e60fe 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -31,7 +31,6 @@ struct HomeCommonMenuStore { "pm", "location_report", "report_photographer", - "promotion_page", "registration_invitation", "store", "fly", @@ -45,6 +44,11 @@ struct HomeCommonMenuStore { "cooperation_order" ] + /// 已从 iOS 移除的首页菜单 URI,即使后端仍下发权限也不展示。 + static let hiddenHomeMenuURIs: Set = [ + "promotion_page" + ] + private let defaults: UserDefaults /// 初始化常用应用存储服务,并允许测试注入独立 UserDefaults。 @@ -129,9 +133,11 @@ struct HomeCommonMenuStore { let finalCommon: [String] if !saved.isEmpty { - finalCommon = saved.filter { availableTopLevelSet.contains($0) } + finalCommon = saved + .filter { availableTopLevelSet.contains($0) } + .filter { !Self.hiddenHomeMenuURIs.contains($0) } } else { - finalCommon = defaultCommon + finalCommon = defaultCommon.filter { !Self.hiddenHomeMenuURIs.contains($0) } save(finalCommon, roleCode: roleCode, accountScope: accountScope) } @@ -216,6 +222,6 @@ struct HomeCommonMenuStore { /// 判断 URI 是否属于 Android 首页菜单白名单。 private static func isAndroidHomeMenuURI(_ uri: String) -> Bool { - androidHomeMenuURIs.contains(uri) + androidHomeMenuURIs.contains(uri) && !hiddenHomeMenuURIs.contains(uri) } } diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index 15fc493..8abea4a 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -80,6 +80,7 @@ final class HomeViewModel: ObservableObject { var seen = Set() var deduplicated: [PermissionItem] = [] for item in permissionItems where !item.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + guard !HomeCommonMenuStore.hiddenHomeMenuURIs.contains(item.uri) else { continue } if seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted { deduplicated.append(item) } diff --git a/suixinkan/Features/Home/Views/HomeIconCatalog.swift b/suixinkan/Features/Home/Views/HomeIconCatalog.swift index 1489092..3faa94f 100644 --- a/suixinkan/Features/Home/Views/HomeIconCatalog.swift +++ b/suixinkan/Features/Home/Views/HomeIconCatalog.swift @@ -56,8 +56,6 @@ 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 f2c3d99..3b1e397 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -112,8 +112,6 @@ 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 deleted file mode 100644 index 6221076..0000000 --- a/suixinkan/Features/ScenicPromotion/Models/ScenicPromotionModels.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// 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 deleted file mode 100644 index 8878fdb..0000000 --- a/suixinkan/Features/ScenicPromotion/ScenicPromotion.md +++ /dev/null @@ -1,82 +0,0 @@ -# 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/ScenicPromotionView.swift` — 主页面入口 -- `Views/ScenicPromotionContentView.swift` — 主页面内容组装 -- `Views/ScenicPromotionSettingsView.swift` — 设置页入口 -- `Views/ScenicPromotionPreviewSupport.swift` — Preview 工厂(DEBUG) -- 其余 `Views/ScenicPromotion*.swift` — 各业务子视图,均含 `#Preview` diff --git a/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift b/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift deleted file mode 100644 index fd07fd1..0000000 --- a/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift +++ /dev/null @@ -1,543 +0,0 @@ -// -// 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 - ) - ] - ), - ScenicPromotionSpot( - id: "eagle", - name: "猎鹰台", - iconName: "binoculars.fill", - queue: ScenicPromotionQueueInfo( - waitingPeople: 41, - estimatedMinutes: 14, - averageMinutes: 13, - nextNumber: "E042", - currentNumber: "E041", - shootingDuration: "8分钟50秒" - ), - videos: [ - ScenicPromotionMedia( - id: "eagle_video_1", - mode: .video, - title: "猎鹰台", - subtitle: "俯瞰草原与河谷全景", - duration: "00:30", - palette: [0x0B78F6, 0xB4E8FF, 0x4E9D3B], - assetName: "ScenicPromotionThumbMarker", - heroAssetName: "ScenicPromotionHero", - previewWidth: 144 - ), - ScenicPromotionMedia( - id: "eagle_video_2", - mode: .video, - title: "日落观景", - subtitle: "金色余晖下的草原层次", - duration: "00:27", - palette: [0xE8A030, 0xFFE8A8, 0x4AAE57], - assetName: "ScenicPromotionThumbGrass", - heroAssetName: nil, - previewWidth: 72 - ) - ], - photos: [ - ScenicPromotionMedia( - id: "eagle_photo_1", - mode: .photo, - title: "全景远眺", - subtitle: "猎鹰台俯瞰草原与远山", - duration: nil, - palette: [0x167CFF, 0xBDEBFF, 0x58B15D], - assetName: "ScenicPromotionThumbMarker", - heroAssetName: nil, - previewWidth: 144 - ), - ScenicPromotionMedia( - id: "eagle_photo_2", - mode: .photo, - title: "日落剪影", - subtitle: "游客与落日同框", - duration: nil, - palette: [0xE8A030, 0xFFE8A8, 0x4AAE57], - assetName: "ScenicPromotionThumbPortrait", - heroAssetName: nil, - previewWidth: 72 - ) - ] - ), - ScenicPromotionSpot( - id: "snowlotus", - name: "雪莲谷", - iconName: "water.waves", - queue: ScenicPromotionQueueInfo( - waitingPeople: 15, - estimatedMinutes: 7, - averageMinutes: 11, - nextNumber: "L016", - currentNumber: "L015", - shootingDuration: "6分钟30秒" - ), - videos: [ - ScenicPromotionMedia( - id: "snowlotus_video_1", - mode: .video, - title: "雪莲谷", - subtitle: "溪流、草甸与雪山倒影", - duration: "00:25", - palette: [0x1D8BFF, 0x8FD7FF, 0x2F8F45], - assetName: "ScenicPromotionThumbGrass", - heroAssetName: nil, - previewWidth: 96 - ) - ], - photos: [ - ScenicPromotionMedia( - id: "snowlotus_photo_1", - mode: .photo, - title: "溪流倒影", - subtitle: "清澈溪水与雪山同框", - duration: nil, - palette: [0x1D8BFF, 0x8FD7FF, 0x2F8F45], - assetName: "ScenicPromotionThumbGrass", - heroAssetName: nil, - previewWidth: 96 - ), - ScenicPromotionMedia( - id: "snowlotus_photo_2", - mode: .photo, - title: "谷地花海", - subtitle: "溪流旁野花点缀", - duration: nil, - palette: [0x3CA7FF, 0xC8F1FF, 0x4AAE57], - assetName: "ScenicPromotionThumbPortrait", - heroAssetName: nil, - previewWidth: 72 - ) - ] - ), - ScenicPromotionSpot( - id: "nomad", - name: "游牧人家", - iconName: "tent.fill", - queue: ScenicPromotionQueueInfo( - waitingPeople: 36, - estimatedMinutes: 13, - averageMinutes: 15, - nextNumber: "N037", - currentNumber: "N036", - shootingDuration: "9分钟40秒" - ), - videos: [ - ScenicPromotionMedia( - id: "nomad_video_1", - mode: .video, - title: "游牧人家", - subtitle: "毡房、牛羊与草原生活", - duration: "00:31", - palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D], - assetName: "ScenicPromotionThumbHorse", - heroAssetName: nil, - previewWidth: 96 - ), - ScenicPromotionMedia( - id: "nomad_video_2", - mode: .video, - title: "毡房旅拍", - subtitle: "民族服饰与草原背景", - duration: "00:23", - palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C], - assetName: "ScenicPromotionThumbPortrait", - heroAssetName: nil, - previewWidth: 72 - ) - ], - photos: [ - ScenicPromotionMedia( - id: "nomad_photo_1", - mode: .photo, - title: "毡房合影", - subtitle: "传统毡房前的旅拍样片", - duration: nil, - palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D], - assetName: "ScenicPromotionThumbHorse", - heroAssetName: nil, - previewWidth: 96 - ), - ScenicPromotionMedia( - id: "nomad_photo_2", - mode: .photo, - title: "民族服饰", - subtitle: "游客身着民族服装打卡", - duration: nil, - palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C], - assetName: "ScenicPromotionThumbPortrait", - heroAssetName: nil, - previewWidth: 72 - ), - ScenicPromotionMedia( - id: "nomad_photo_3", - mode: .photo, - title: "草原牧群", - subtitle: "牛羊与远山层次构图", - duration: nil, - palette: [0x2A7BEF, 0xB7E5FF, 0x74B84A], - assetName: "ScenicPromotionThumbGrass", - heroAssetName: nil, - previewWidth: 144 - ) - ] - ) - ] - ) - - /// 设置页排队打卡点 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 deleted file mode 100644 index 0a44e7d..0000000 --- a/suixinkan/Features/ScenicPromotion/ViewModels/ScenicPromotionViewModels.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// 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]] = [:] - /// 当前要预览的已上传素材。 - @Published var previewMaterial: 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 openMaterialPreview(_ material: ScenicPromotionManagedMaterial) { - previewMaterial = material - } - - /// 提交前校验至少绑定一个排队打卡点。 - func submit() -> Bool { - guard !selectedQueuePointIDs.isEmpty else { return false } - return true - } -} diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionBackgroundViews.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionBackgroundViews.swift deleted file mode 100644 index d3ed371..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionBackgroundViews.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// ScenicPromotionBackgroundViews.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页全屏背景,叠加轻量蓝绿色遮罩以贴近参考图。 -struct ScenicPromotionSkyBackground: View { - var body: some View { - GeometryReader { proxy in - ZStack(alignment: .bottom) { - Image("ScenicPromotionBackground") - .resizable() - .scaledToFill() - // 先限定为容器尺寸,再裁剪超出部分(scaledToFill 会放大图片)。 - .frame(width: proxy.size.width, height: proxy.size.height) - .clipped() - - 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) - } - .frame(width: proxy.size.width, height: proxy.size.height) - .clipped() - } - } -} - -/// 顶部二维码图片,快速点击三次进入设置页密码弹窗。 -struct ScenicPromotionQRCodeView: View { - var body: some View { - Image("ScenicPromotionQRCode") - .resizable() - .scaledToFit() - } -} - -#if DEBUG -#Preview("宣传页背景") { - ScenicPromotionSkyBackground() - .ignoresSafeArea() -} - -#Preview("宣传页二维码") { - ScenicPromotionQRCodeView() - .frame(width: 56, height: 56) - .padding() - .background(.white) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionContentView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionContentView.swift deleted file mode 100644 index c04a0c2..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionContentView.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// ScenicPromotionContentView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页正常内容区,组装各业务子模块。 -struct ScenicPromotionContentView: View { - @ObservedObject var viewModel: ScenicPromotionViewModel - let onQRCodeTap: () -> Void - let onQRCodeLongPress: () -> Void - let onVideoTap: () -> Void - - var body: some View { - VStack(spacing: ScenicPromotionLayout.moduleVerticalSpacing) { - if let board = viewModel.board { - ScenicPromotionHeaderView( - board: board, - onQRCodeTap: onQRCodeTap, - onQRCodeLongPress: onQRCodeLongPress - ) - ScenicPromotionSpotTabsView( - spots: board.spots, - selectedSpotID: viewModel.selectedSpot?.id, - onSelectSpot: viewModel.selectSpot - ) - ScenicPromotionMetricsGridView(queue: viewModel.queue) - ScenicPromotionQueuePanelView(queue: viewModel.queue) - ScenicPromotionMediaToolbarView( - selectedMode: viewModel.selectedMode, - onSelectMode: viewModel.selectMode - ) - ScenicPromotionMediaStripView( - mediaItems: viewModel.mediaItems, - selectedMediaID: viewModel.selectedMedia?.id, - emptyMode: viewModel.selectedMode, - onSelectMedia: viewModel.selectMedia, - onMoveMedia: viewModel.moveMedia - ) - ScenicPromotionHeroMediaView( - selectedMedia: viewModel.selectedMedia, - emptyMode: viewModel.selectedMode, - onVideoTap: onVideoTap - ) - } - } - .border(.blue) - .padding(.top, 28) - .padding(.bottom, 24) - .border(.red) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.horizontal, ScenicPromotionLayout.screenEdgePadding) - } -} - -#if DEBUG -#Preview("宣传页内容") { - ZStack { - ScenicPromotionSkyBackground().ignoresSafeArea() - ScenicPromotionContentView( - viewModel: ScenicPromotionPreviewSupport.mainViewModel(), - onQRCodeTap: {}, - onQRCodeLongPress: {}, - onVideoTap: {} - ) - } -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionFullStateView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionFullStateView.swift deleted file mode 100644 index 693d83d..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionFullStateView.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// ScenicPromotionFullStateView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页 Empty / Error 通用状态视图。 -struct ScenicPromotionFullStateView: View { - let icon: String - let title: String - let message: String - let buttonTitle: String - let action: () -> Void - - var body: 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) - } -} - -#if DEBUG -#Preview("空数据") { - ZStack { - ScenicPromotionSkyBackground().ignoresSafeArea() - ScenicPromotionFullStateView( - icon: "doc.text.image", - title: "暂无宣传页数据", - message: "当前景区还没有配置排队导览宣传内容", - buttonTitle: "重新加载" - ) {} - } -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeaderView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeaderView.swift deleted file mode 100644 index aca6c5c..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeaderView.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// ScenicPromotionHeaderView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页顶部标题、说明文案和二维码入口。 -struct ScenicPromotionHeaderView: View { - let board: ScenicPromotionBoard - let onQRCodeTap: () -> Void - let onQRCodeLongPress: () -> Void - - var body: 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(action: onQRCodeTap) { - 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 onQRCodeLongPress() } - ) - .accessibilityLabel("二维码设置入口") - } - .frame(height: 96) - } -} - -#if DEBUG -#Preview("顶部标题区") { - ScenicPromotionHeaderView( - board: ScenicPromotionPreviewSupport.sampleBoard, - onQRCodeTap: {}, - onQRCodeLongPress: {} - ) - .padding(.horizontal, 20) - .background(ScenicPromotionSkyBackground()) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeroMediaView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeroMediaView.swift deleted file mode 100644 index 986d3f8..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeroMediaView.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// ScenicPromotionHeroMediaView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页底部主视觉区域,展示当前选中的视频或图片。 -struct ScenicPromotionHeroMediaView: View { - let selectedMedia: ScenicPromotionMedia? - let emptyMode: ScenicPromotionMediaMode - let onVideoTap: () -> Void - - var body: some View { - Group { - if let media = selectedMedia { - Button { - if media.mode == .video { - onVideoTap() - } - } label: { - ZStack(alignment: .bottomLeading) { - ScenicPromotionMediaArtwork(media: media, compact: false, useHeroAsset: true) - .frame(maxWidth: .infinity) - .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 { - ScenicPromotionMediaEmptyView(mode: emptyMode) - .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) - ) - } - } - } -} - -#if DEBUG -#Preview("主视觉") { - ScenicPromotionHeroMediaView( - selectedMedia: ScenicPromotionPreviewSupport.sampleVideoMedia, - emptyMode: .video, - onVideoTap: {} - ) - .padding(.horizontal, 20) -} - -#Preview("主视觉-空") { - ScenicPromotionHeroMediaView( - selectedMedia: nil, - emptyMode: .video, - onVideoTap: {} - ) - .padding(.horizontal, 20) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLayout.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLayout.swift deleted file mode 100644 index 1e5f8d3..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLayout.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ScenicPromotionLayout.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import CoreGraphics -import Foundation - -/// 宣传页布局常量,统一管理边距与模块尺寸。 -enum ScenicPromotionLayout { - /// 主页面内容距离屏幕左右边缘各 20px,不代表模块之间的间距。 - static let screenEdgePadding: CGFloat = 20 - /// 模块之间保持轻量垂直间距,避免误用 20px 做模块间隔。 - static let moduleVerticalSpacing: CGFloat = 12 - /// 打卡点标签区最多一屏露出 5 个,更多时横向滚动。 - static let visibleSpotTabCount: CGFloat = 5 -} - -/// 宣传页隐藏设置页路由实体。 -struct ScenicPromotionSettingsRoute: Identifiable, Hashable { - let id = "promotion_page_settings" -} diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLoadingView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLoadingView.swift deleted file mode 100644 index 9fdccd0..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLoadingView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// ScenicPromotionLoadingView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页 Loading 骨架屏,尺寸跟正常模块保持一致。 -struct ScenicPromotionLoadingView: View { - var body: 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) - .frame(maxWidth: .infinity, alignment: .top) - } - } -} - -#if DEBUG -#Preview("加载中") { - ZStack { - ScenicPromotionSkyBackground().ignoresSafeArea() - ScenicPromotionLoadingView() - } -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMaterialPreviewView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMaterialPreviewView.swift deleted file mode 100644 index e8c9421..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMaterialPreviewView.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// ScenicPromotionMaterialPreviewView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 设置页素材预览视图。 -struct ScenicPromotionMaterialPreviewView: View { - 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() - - ScenicPromotionMaterialPreviewInfoRow(title: "素材类型", value: material.kind.shortTitle) - ScenicPromotionMaterialPreviewInfoRow(title: "上传时间", value: material.createdAt) - if let duration = material.duration { - ScenicPromotionMaterialPreviewInfoRow(title: "视频时长", value: duration) - } - } - .padding(16) - .background(.white, in: RoundedRectangle(cornerRadius: 16)) - } - .padding(16) - } - .background(AppDesign.background.ignoresSafeArea()) - .navigationTitle(material.kind == .video ? "视频预览" : "图片预览") - .navigationBarTitleDisplayMode(.inline) - } -} - -/// 素材预览详情中的单行字段。 -struct ScenicPromotionMaterialPreviewInfoRow: View { - let title: String - let value: String - - var body: 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) - } - } -} - -#if DEBUG -#Preview("视频预览") { - NavigationStack { - ScenicPromotionMaterialPreviewView( - material: ScenicPromotionPreviewSupport.sampleManagedMaterial - ) - } -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaArtworkView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaArtworkView.swift deleted file mode 100644 index 8f14a94..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaArtworkView.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// ScenicPromotionMediaArtworkView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页素材图片渲染组件,支持缩略图与主视觉两种尺寸。 -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)) - } -} - -/// 没有本地图片资源时使用的备用山形占位图。 -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 - } -} - -#if DEBUG -#Preview("素材缩略图") { - ScenicPromotionMediaArtwork( - media: ScenicPromotionPreviewSupport.sampleVideoMedia, - compact: true, - useHeroAsset: false - ) - .frame(width: 72, height: 66) - .padding() -} - -#Preview("素材主视觉") { - ScenicPromotionMediaArtwork( - media: ScenicPromotionPreviewSupport.sampleVideoMedia, - compact: false, - useHeroAsset: true - ) - .frame(maxWidth: .infinity) - .frame(height: 220) - .padding() -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaEmptyView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaEmptyView.swift deleted file mode 100644 index 0713154..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaEmptyView.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// ScenicPromotionMediaEmptyView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页当前视频 / 照片模式没有素材时展示的空状态。 -struct ScenicPromotionMediaEmptyView: View { - let mode: ScenicPromotionMediaMode - - var body: some View { - VStack(spacing: 12) { - Image(systemName: mode == .video ? "play.rectangle.fill" : "photo.on.rectangle.angled") - .font(.system(size: 44, weight: .semibold)) - .foregroundStyle(Color(hex: 0x57A2FF)) - Text(mode == .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) - } -} - -#if DEBUG -#Preview("素材空状态-视频") { - ScenicPromotionMediaEmptyView(mode: .video) - .padding() - .background(.white.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) - .padding() -} - -#Preview("素材空状态-照片") { - ScenicPromotionMediaEmptyView(mode: .photo) - .padding() - .background(.white.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) - .padding() -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaStripView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaStripView.swift deleted file mode 100644 index 70947f7..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaStripView.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// ScenicPromotionMediaStripView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页素材横向选择器,支持左右切换与缩略图选中。 -struct ScenicPromotionMediaStripView: View { - let mediaItems: [ScenicPromotionMedia] - let selectedMediaID: String? - let emptyMode: ScenicPromotionMediaMode - let onSelectMedia: (ScenicPromotionMedia) -> Void - let onMoveMedia: (Int) -> Void - - var body: some View { - HStack(spacing: 8) { - Button { - onMoveMedia(-1) - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 22, weight: .heavy)) - .foregroundStyle(Color(hex: 0x326EAE)) - .frame(width: 28, height: 100) - } - .buttonStyle(.plain) - - if mediaItems.isEmpty { - ScenicPromotionMediaEmptyView(mode: emptyMode) - } else { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(mediaItems) { media in - Button { - onSelectMedia(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( - selectedMediaID == media.id ? Color(hex: 0x0B79FF) : .white.opacity(0.75), - lineWidth: selectedMediaID == media.id ? 3 : 1 - ) - ) - } - .buttonStyle(.plain) - } - } - .padding(.vertical, 2) - } - } - - Button { - onMoveMedia(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) - ) - } -} - -#if DEBUG -#Preview("素材横向列表") { - let board = ScenicPromotionPreviewSupport.sampleBoard - ScenicPromotionMediaStripView( - mediaItems: board.spots[0].videos, - selectedMediaID: board.spots[0].videos[0].id, - emptyMode: .video, - onSelectMedia: { _ in }, - onMoveMedia: { _ in } - ) - .padding(.horizontal, 20) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaToolbarView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaToolbarView.swift deleted file mode 100644 index 31e4c57..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaToolbarView.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// ScenicPromotionMediaToolbarView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页视频 / 照片切换工具条。 -struct ScenicPromotionMediaToolbarView: View { - let selectedMode: ScenicPromotionMediaMode - let onSelectMode: (ScenicPromotionMediaMode) -> Void - - var body: some View { - HStack(spacing: 14) { - HStack(spacing: 0) { - ForEach(ScenicPromotionMediaMode.allCases) { mode in - Button { - onSelectMode(mode) - } label: { - HStack(spacing: 6) { - Image(systemName: mode.iconName) - Text(mode.title) - } - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(selectedMode == mode ? .white : Color(hex: 0x1B4D91)) - .frame(width: 68, height: 34) - .background( - 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) - } - } -} - -#if DEBUG -#Preview("素材切换工具条") { - ScenicPromotionMediaToolbarView(selectedMode: .video, onSelectMode: { _ in }) - .padding(.horizontal, 20) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMetricsGridView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMetricsGridView.swift deleted file mode 100644 index 9171ad7..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMetricsGridView.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// ScenicPromotionMetricsGridView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页三个排队数据卡片:当前排队人数、预计等待时间、平均等待时长。 -struct ScenicPromotionMetricsGridView: View { - let queue: ScenicPromotionQueueInfo? - - var body: some View { - 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)) - ] - - 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) - ) - } - } - } -} - -#if DEBUG -#Preview("排队数据") { - ScenicPromotionMetricsGridView(queue: ScenicPromotionPreviewSupport.sampleQueue) - .padding(.horizontal, 20) -} - -#Preview("排队数据-空") { - ScenicPromotionMetricsGridView(queue: nil) - .padding(.horizontal, 20) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionPreviewSupport.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionPreviewSupport.swift deleted file mode 100644 index 6e23522..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionPreviewSupport.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// ScenicPromotionPreviewSupport.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import Foundation - -#if DEBUG -/// 宣传页 Preview 工厂,提供预置 ViewModel 与 Mock 数据。 -@MainActor -enum ScenicPromotionPreviewSupport { - /// 返回指定演示场景的主页面 ViewModel。 - static func mainViewModel(scenario: ScenicPromotionDemoScenario = .normal) -> ScenicPromotionViewModel { - let viewModel = ScenicPromotionViewModel() - viewModel.applyScenario(scenario) - return viewModel - } - - /// 返回指定演示场景的设置页 ViewModel。 - static func settingsViewModel(scenario: ScenicPromotionSettingsDemoScenario = .normal) -> ScenicPromotionSettingsViewModel { - let viewModel = ScenicPromotionSettingsViewModel() - viewModel.applyScenario(scenario) - return viewModel - } - - /// 预览用看板数据。 - static var sampleBoard: ScenicPromotionBoard { - ScenicPromotionMockStore.mockBoard - } - - /// 预览用排队信息。 - static var sampleQueue: ScenicPromotionQueueInfo { - sampleBoard.spots[0].queue! - } - - /// 预览用视频素材。 - static var sampleVideoMedia: ScenicPromotionMedia { - sampleBoard.spots[0].videos[0] - } - - /// 预览用已上传素材。 - static var sampleManagedMaterial: ScenicPromotionManagedMaterial { - ScenicPromotionMockStore.mockMaterialsByPointID["material_bridge"]!.first! - } -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionQueuePanelView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionQueuePanelView.swift deleted file mode 100644 index babecbe..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionQueuePanelView.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// ScenicPromotionQueuePanelView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页当前叫号模块,有排队数据时展示叫号,无排队时展示空状态。 -struct ScenicPromotionQueuePanelView: View { - let queue: ScenicPromotionQueueInfo? - - var body: some View { - Group { - if let queue { - HStack(spacing: 10) { - ScenicPromotionQueueSideView( - 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) - ScenicPromotionQueueSideView( - 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) - } -} - -/// 叫号模块左右两侧的小信息块。 -struct ScenicPromotionQueueSideView: View { - let icon: String - let title: String - let value: String - let tint: Color - - var body: 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) - } -} - -#if DEBUG -#Preview("叫号面板") { - ScenicPromotionQueuePanelView(queue: ScenicPromotionPreviewSupport.sampleQueue) - .padding(.horizontal, 20) -} - -#Preview("叫号面板-空") { - ScenicPromotionQueuePanelView(queue: nil) - .padding(.horizontal, 20) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsContentView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsContentView.swift deleted file mode 100644 index f3d06ff..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsContentView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// ScenicPromotionSettingsContentView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 设置页正常内容区域。 -struct ScenicPromotionSettingsContentView: View { - @ObservedObject var viewModel: ScenicPromotionSettingsViewModel - let onUploadFailed: () -> Void - - var body: some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 16) { - ScenicPromotionSettingsHeaderView() - ScenicPromotionSettingsQueueBindingCard(viewModel: viewModel) - ScenicPromotionSettingsMaterialPointCard(viewModel: viewModel) - ScenicPromotionSettingsMaterialUploadCard( - viewModel: viewModel, - kind: .video, - onUploadFailed: onUploadFailed - ) - ScenicPromotionSettingsMaterialUploadCard( - viewModel: viewModel, - kind: .image, - onUploadFailed: onUploadFailed - ) - } - .padding(.horizontal, 16) - .padding(.top, 14) - .padding(.bottom, 28) - } - } -} - -#if DEBUG -#Preview("设置页内容") { - ScenicPromotionSettingsContentView( - viewModel: ScenicPromotionPreviewSupport.settingsViewModel(), - onUploadFailed: {} - ) - .background(AppDesign.background) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsHeaderView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsHeaderView.swift deleted file mode 100644 index 027e503..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsHeaderView.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// ScenicPromotionSettingsHeaderView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 设置页顶部说明模块。 -struct ScenicPromotionSettingsHeaderView: View { - var body: 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) - ) - } -} - -#if DEBUG -#Preview("设置页头部") { - ScenicPromotionSettingsHeaderView() - .padding() - .background(AppDesign.background) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialPointCard.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialPointCard.swift deleted file mode 100644 index 6c02bc7..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialPointCard.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// ScenicPromotionSettingsMaterialPointCard.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 设置页素材所属打卡点选择卡片。 -struct ScenicPromotionSettingsMaterialPointCard: View { - @ObservedObject var viewModel: ScenicPromotionSettingsViewModel - - var body: some View { - ScenicPromotionSettingsCard( - 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: { - ScenicPromotionSettingsSelectionField( - title: viewModel.selectedMaterialPointName, - subtitle: viewModel.selectedMaterialPoint == nil ? "未选择" : "已展示当前点位素材", - icon: "chevron.down" - ) - } - .buttonStyle(.plain) - } - } -} - -#if DEBUG -#Preview("素材点卡片") { - ScenicPromotionSettingsMaterialPointCard( - viewModel: ScenicPromotionPreviewSupport.settingsViewModel() - ) - .padding() - .background(AppDesign.background) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialRowView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialRowView.swift deleted file mode 100644 index 438b462..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialRowView.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// ScenicPromotionSettingsMaterialRowView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 设置页已上传素材行,支持预览与删除。 -struct ScenicPromotionSettingsMaterialRowView: View { - let material: ScenicPromotionManagedMaterial - let onPreview: () -> Void - let onDelete: () -> Void - - var body: some View { - HStack(spacing: 10) { - Button(action: onPreview) { - HStack(spacing: 12) { - ScenicPromotionSettingsMaterialThumbnail(material: 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(action: onDelete) { - 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)) - } -} - -#if DEBUG -#Preview("素材行") { - ScenicPromotionSettingsMaterialRowView( - material: ScenicPromotionPreviewSupport.sampleManagedMaterial, - onPreview: {}, - onDelete: {} - ) - .padding() - .background(AppDesign.background) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialUploadCard.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialUploadCard.swift deleted file mode 100644 index 062ef91..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialUploadCard.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// ScenicPromotionSettingsMaterialUploadCard.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 设置页视频 / 图片上传模块。 -struct ScenicPromotionSettingsMaterialUploadCard: View { - @ObservedObject var viewModel: ScenicPromotionSettingsViewModel - let kind: ScenicPromotionUploadKind - let onUploadFailed: () -> Void - - var body: some View { - let materials = viewModel.materials(for: kind) - - ScenicPromotionSettingsCard( - 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 { - guard viewModel.addMaterial(kind: kind) else { - onUploadFailed() - return - } - } 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 { - ScenicPromotionSettingsInlineNotice( - text: "请选择素材所属打卡点后再上传\(kind.shortTitle)", - tint: AppDesign.warning - ) - } else if materials.isEmpty { - ScenicPromotionSettingsMaterialEmptyView(kind: kind) - } else { - VStack(spacing: 10) { - ForEach(materials) { material in - ScenicPromotionSettingsMaterialRowView( - material: material, - onPreview: { viewModel.openMaterialPreview(material) }, - onDelete: { viewModel.deleteMaterial(material) } - ) - } - } - } - } - } -} - -#if DEBUG -#Preview("视频上传卡片") { - ScenicPromotionSettingsMaterialUploadCard( - viewModel: ScenicPromotionPreviewSupport.settingsViewModel(), - kind: .video, - onUploadFailed: {} - ) - .padding() - .background(AppDesign.background) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsQueueBindingCard.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsQueueBindingCard.swift deleted file mode 100644 index 3c9ee0b..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsQueueBindingCard.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// ScenicPromotionSettingsQueueBindingCard.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 设置页排队打卡点绑定卡片,支持多选。 -struct ScenicPromotionSettingsQueueBindingCard: View { - @ObservedObject var viewModel: ScenicPromotionSettingsViewModel - - var body: some View { - ScenicPromotionSettingsCard( - 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: { - ScenicPromotionSettingsSelectionField( - title: viewModel.selectedQueuePointNames, - subtitle: "\(viewModel.selectedQueuePointIDs.count) 个已选择", - icon: "chevron.down" - ) - } - .buttonStyle(.plain) - - if viewModel.selectedQueuePointIDs.isEmpty { - ScenicPromotionSettingsInlineNotice( - text: "提交前至少选择一个排队打卡点", - tint: AppDesign.warning - ) - } - } - } -} - -#if DEBUG -#Preview("排队绑定卡片") { - ScenicPromotionSettingsQueueBindingCard( - viewModel: ScenicPromotionPreviewSupport.settingsViewModel() - ) - .padding() - .background(AppDesign.background) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSharedViews.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSharedViews.swift deleted file mode 100644 index b6872f9..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSharedViews.swift +++ /dev/null @@ -1,234 +0,0 @@ -// -// ScenicPromotionSettingsSharedViews.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 设置页通用卡片容器。 -struct ScenicPromotionSettingsCard: View { - let title: String - let subtitle: String - let icon: String - let tint: Color - @ViewBuilder let content: Content - - var body: 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) - ) - } -} - -/// 设置页下拉选择框样式。 -struct ScenicPromotionSettingsSelectionField: View { - let title: String - let subtitle: String - let icon: String - - var body: 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) - ) - } -} - -/// 设置页表单内联提示。 -struct ScenicPromotionSettingsInlineNotice: View { - let text: String - let tint: Color - - var body: 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)) - } -} - -/// 设置页某个素材点当前没有素材时的空状态。 -struct ScenicPromotionSettingsMaterialEmptyView: View { - let kind: ScenicPromotionUploadKind - - var body: 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)) - } -} - -/// 设置页素材行缩略图。 -struct ScenicPromotionSettingsMaterialThumbnail: View { - let material: ScenicPromotionManagedMaterial - - var body: 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 骨架屏。 -struct ScenicPromotionSettingsLoadingView: View { - var body: 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 通用状态视图。 -struct ScenicPromotionSettingsFullStateView: View { - let icon: String - let title: String - let message: String - let buttonTitle: String - let action: () -> Void - - var body: 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) - } -} - -#if DEBUG -#Preview("设置卡片") { - ScenicPromotionSettingsCard( - title: "排队打卡点绑定", - subtitle: "可多选,用于宣传页展示排队动态", - icon: "checklist.checked", - tint: AppDesign.primary - ) { - ScenicPromotionSettingsSelectionField( - title: "网红桥排队点、汉地拔冲排队点", - subtitle: "2 个已选择", - icon: "chevron.down" - ) - } - .padding() - .background(AppDesign.background) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSubmitBarView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSubmitBarView.swift deleted file mode 100644 index b05a508..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSubmitBarView.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// ScenicPromotionSettingsSubmitBarView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 设置页底部悬浮提交栏。 -struct ScenicPromotionSettingsSubmitBarView: View { - let onSubmit: () -> Void - - var body: some View { - VStack(spacing: 0) { - Divider() - Button(action: onSubmit) { - 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) - } -} - -#if DEBUG -#Preview("提交栏") { - ScenicPromotionSettingsSubmitBarView(onSubmit: {}) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsView.swift deleted file mode 100644 index e7b9b94..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsView.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// ScenicPromotionSettingsView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页设置视图,管理排队绑定、素材点与 Mock 上传。 -struct ScenicPromotionSettingsView: View { - @StateObject private var viewModel = ScenicPromotionSettingsViewModel() - @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: - ScenicPromotionSettingsLoadingView() - .transition(.opacity.combined(with: .scale(scale: 0.98))) - case .empty: - ScenicPromotionSettingsFullStateView( - icon: "tray", - title: "暂无设置数据", - message: "当前没有可配置的排队打卡点和素材打卡点", - buttonTitle: "重新加载" - ) { - Task { await viewModel.load() } - } - .transition(.opacity.combined(with: .move(edge: .bottom))) - case .error(let message): - ScenicPromotionSettingsFullStateView( - icon: "wifi.exclamationmark", - title: "加载失败", - message: message, - buttonTitle: "重试" - ) { - Task { await viewModel.load() } - } - .transition(.opacity.combined(with: .move(edge: .bottom))) - case .normal: - ScenicPromotionSettingsContentView( - viewModel: viewModel, - onUploadFailed: { showSelectMaterialPointAlert = true } - ) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .animation(.easeInOut(duration: 0.24), value: viewModel.pageState) - } - .navigationTitle("宣传页设置") - .navigationBarTitleDisplayMode(.inline) - .appNavigationDestination(item: $viewModel.previewMaterial) { material in - ScenicPromotionMaterialPreviewView(material: material) - } - .safeAreaInset(edge: .bottom) { - if case .normal = viewModel.pageState { - ScenicPromotionSettingsSubmitBarView(onSubmit: handleSubmit) - } - } - .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() - } - } - - /// 提交设置并展示 Mock 结果提示。 - private func handleSubmit() { - if viewModel.submit() { - submitMessage = "宣传页设置已保存,当前为 Mock 演示数据。" - } else { - submitMessage = "请至少绑定一个排队打卡点。" - } - showSubmitAlert = true - } -} - -#if DEBUG -#Preview("宣传页设置") { - NavigationStack { - ScenicPromotionSettingsView() - } -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSpotTabsView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSpotTabsView.swift deleted file mode 100644 index ba90fda..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSpotTabsView.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// ScenicPromotionSpotTabsView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页打卡点标签区,5 个以内等分,超过 5 个时横向滚动并将选中项居中。 -struct ScenicPromotionSpotTabsView: View { - let spots: [ScenicPromotionSpot] - let selectedSpotID: String? - let onSelectSpot: (ScenicPromotionSpot) -> Void - - /// 选中背景滑块动画曲线。 - private static let selectionAnimation = Animation.spring(response: 0.35, dampingFraction: 0.82) - /// 选中标签滚动居中动画曲线。 - private static let scrollAnimation = Animation.easeInOut(duration: 0.25) - - /// 当前选中标签在列表中的索引,未选中时回退到 0。 - private var selectedIndex: Int { - guard let selectedSpotID else { return 0 } - return spots.firstIndex(where: { $0.id == selectedSpotID }) ?? 0 - } - - /// 是否需要启用横向滚动(超过 5 个标签)。 - private var shouldEnableScroll: Bool { - spots.count > Int(ScenicPromotionLayout.visibleSpotTabCount) - } - - var body: some View { - if spots.isEmpty { - EmptyView() - } else { - Color.clear - .frame(maxWidth: .infinity) - .frame(height: 42) - .overlay { - GeometryReader { proxy in - let visibleCount = min( - CGFloat(max(spots.count, 1)), - ScenicPromotionLayout.visibleSpotTabCount - ) - let tabWidth = proxy.size.width / visibleCount - - ScrollViewReader { scrollProxy in - ScrollView(.horizontal, showsIndicators: false) { - ZStack(alignment: .leading) { - selectionBackground(width: tabWidth) - - HStack(spacing: 0) { - ForEach(spots) { spot in - ScenicPromotionSpotTabButton( - spot: spot, - isSelected: selectedSpotID == spot.id, - onTap: { onSelectSpot(spot) } - ) - .frame(width: tabWidth) - .id(spot.id) - } - } - } - } - .scrollDisabled(!shouldEnableScroll) - .onAppear { - scrollSelectedTabToCenter(using: scrollProxy, animated: false) - } - .onChange(of: selectedSpotID) { _ in - scrollSelectedTabToCenter(using: scrollProxy, animated: true) - } - } - } - } - .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) - } - } - - /// 选中标签的渐变背景滑块,随选中项横向位移。 - @ViewBuilder - private func selectionBackground(width: CGFloat) -> some View { - RoundedRectangle(cornerRadius: 14) - .fill( - LinearGradient( - colors: [Color(hex: 0x1286FF), Color(hex: 0x006BFF)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: width, height: 42) - .offset(x: CGFloat(selectedIndex) * width) - .animation(Self.selectionAnimation, value: selectedIndex) - } - - /// 超过可见数量时,将选中标签滚动到可视区域中间。 - private func scrollSelectedTabToCenter(using scrollProxy: ScrollViewProxy, animated: Bool) { - guard shouldEnableScroll, let selectedSpotID else { return } - - if animated { - withAnimation(Self.scrollAnimation) { - scrollProxy.scrollTo(selectedSpotID, anchor: .center) - } - } else { - scrollProxy.scrollTo(selectedSpotID, anchor: .center) - } - } -} - -/// 单个打卡点标签按钮。 -struct ScenicPromotionSpotTabButton: View { - let spot: ScenicPromotionSpot - let isSelected: Bool - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - 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(height: 42) - .padding(.horizontal, 4) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -#if DEBUG -#Preview("打卡点标签") { - ScenicPromotionSpotTabsView( - spots: ScenicPromotionPreviewSupport.sampleBoard.spots, - selectedSpotID: "bridge", - onSelectSpot: { _ in } - ) - .padding(.horizontal, 20) -} -#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionView.swift deleted file mode 100644 index 3498231..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionView.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// ScenicPromotionView.swift -// suixinkan -// -// Created by Codex on 2026/7/2. -// - -import SwiftUI - -/// 宣传页主视图,负责页面状态切换与隐藏设置入口。 -struct ScenicPromotionView: 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: - ScenicPromotionLoadingView() - .transition(.opacity.combined(with: .scale(scale: 0.98))) - case .empty: - ScenicPromotionFullStateView( - icon: "doc.text.image", - title: "暂无宣传页数据", - message: "当前景区还没有配置排队导览宣传内容", - buttonTitle: "重新加载" - ) { - Task { await viewModel.load() } - } - .transition(.opacity.combined(with: .move(edge: .bottom))) - case .error(let message): - ScenicPromotionFullStateView( - icon: "wifi.exclamationmark", - title: "加载失败", - message: message, - buttonTitle: "重试" - ) { - Task { await viewModel.load() } - } - .transition(.opacity.combined(with: .move(edge: .bottom))) - case .normal: - ScenicPromotionContentView( - viewModel: viewModel, - onQRCodeTap: handleQRCodeTap, - onQRCodeLongPress: { showDemoStateMenu = true }, - onVideoTap: { showPlayToast = true } - ) - .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() - } - } - - /// 二维码快速点击 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 - } - } -} - -#if DEBUG -#Preview("宣传页") { - NavigationStack { - ScenicPromotionView() - } -} -#endif diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index 327ca74..35d08c2 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -272,6 +272,8 @@ final class HomeCommonMenuStoreTests: XCTestCase { /// 测试 report_photographer 属于 Android menuList 白名单,且可加入常用应用。 func testPhotographerReportIsWhitelistedAndCanBeAddedToCommon() { XCTAssertTrue(HomeCommonMenuStore.androidHomeMenuURIs.contains("report_photographer")) + XCTAssertFalse(HomeCommonMenuStore.androidHomeMenuURIs.contains("promotion_page")) + XCTAssertTrue(HomeCommonMenuStore.hiddenHomeMenuURIs.contains("promotion_page")) let store = HomeCommonMenuStore(defaults: makeIsolatedDefaults()) let uris = store.add( @@ -285,6 +287,30 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(uris, ["location_report", "report_photographer"]) } + /// 测试已移除的 promotion_page 不会出现在白名单,且会从已保存常用菜单中过滤。 + func testPromotionPageIsHiddenFromCommonMenu() { + let defaults = makeIsolatedDefaults() + defaults.set( + ["wallet", "promotion_page", "message_center"], + forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_promotion") + ) + let store = HomeCommonMenuStore(defaults: defaults) + let menus = [ + menu("wallet"), + menu("promotion_page"), + menu("message_center") + ] + + let uris = store.load( + menuItems: menus, + roleCode: "photographer", + accountScope: "scenic_user_promotion", + topLevelPermissionURIs: menus.map(\.uri) + ) + + XCTAssertEqual(uris, ["wallet", "message_center"]) + } + /// 测试非白名单顶层项占用前 4 名额时,默认常用不会用后续白名单项补位。 func testDefaultCommonDoesNotBackfillAfterNonCatalogSlots() { let uris = HomeCommonMenuStore.defaultCommonURIs( diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 9196ae2..0874b3b 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -48,7 +48,6 @@ 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 deleted file mode 100644 index b9d5f81..0000000 --- a/suixinkanTests/ScenicPromotion/ScenicPromotionTests.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// 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 b4f139f..740c306 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -112,7 +112,7 @@ | 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 | | 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | | 完成 | 举报摄影师 | `photographer_report` 已接真实页面;支持提交举报、我的举报、详情、补充证据、附近风险地图;当前为 Mock 数据闭环。 | 后续接入真实 API 时保持页面字段和交互不变。 | -| 完成 | 宣传页 | `scenic_promotion` 已接 Mock 演示页;支持打卡点切换、排队导览、视频/照片素材、隐藏设置入口。 | 后续接入真实 API 时替换 Mock 数据来源。 | +| 移除 | 宣传页 | `promotion_page` 已从 iOS 移除;首页白名单与常用菜单均隐藏该入口。 | 如后端仍下发权限,客户端静默忽略。 | | 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | | 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 | | 完成 | 合作订单 | `cooperation_order` 已接真实页面;双 Tab 列表(获客线索/获客订单)、合作获客员、扫码绑定、主 Tab 扫码识别 sale_user_bind。 | 与订单模块 orderType 11/14 订单来源联动。 |