新增宣传页 Mock 模块并接入首页路由。
从 xh_test 同步 ScenicPromotion 演示页,补齐 MVVM 结构、本地资源、单元测试与模块文档,注册 promotion_page 首页入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
@ -64,15 +64,11 @@
|
|||||||
};
|
};
|
||||||
A00000022FE9000000000002 /* suixinkanTests */ = {
|
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = suixinkanTests;
|
path = suixinkanTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = suixinkanUITests;
|
path = suixinkanUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -328,10 +324,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";
|
||||||
|
|||||||
21
suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "scenic_promotion_background.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/ScenicPromotionBackground.imageset/scenic_promotion_background.png
vendored
Normal file
|
After Width: | Height: | Size: 409 KiB |
21
suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "scenic_promotion_hero.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/ScenicPromotionHero.imageset/scenic_promotion_hero.png
vendored
Normal file
|
After Width: | Height: | Size: 834 KiB |
21
suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "scenic_promotion_qr.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/ScenicPromotionQRCode.imageset/scenic_promotion_qr.png
vendored
Normal file
|
After Width: | Height: | Size: 40 KiB |
21
suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "scenic_promotion_thumb_grass.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/ScenicPromotionThumbGrass.imageset/scenic_promotion_thumb_grass.png
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
21
suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "scenic_promotion_thumb_horse.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/ScenicPromotionThumbHorse.imageset/scenic_promotion_thumb_horse.png
vendored
Normal file
|
After Width: | Height: | Size: 48 KiB |
21
suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "scenic_promotion_thumb_marker.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/ScenicPromotionThumbMarker.imageset/scenic_promotion_thumb_marker.png
vendored
Normal file
|
After Width: | Height: | Size: 100 KiB |
21
suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "scenic_promotion_thumb_portrait.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/ScenicPromotionThumbPortrait.imageset/scenic_promotion_thumb_portrait.png
vendored
Normal file
|
After Width: | Height: | Size: 50 KiB |
@ -63,3 +63,5 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||||
|
|
||||||
`report_photographer` 已由 `Features/WildPhotographerReport` 接管。
|
`report_photographer` 已由 `Features/WildPhotographerReport` 接管。
|
||||||
|
|
||||||
|
`promotion_page` 已由 `Features/ScenicPromotion` 接管。
|
||||||
|
|||||||
@ -68,6 +68,7 @@ enum HomeRoute: Hashable {
|
|||||||
case travelAlbumEntry
|
case travelAlbumEntry
|
||||||
case operatingArea
|
case operatingArea
|
||||||
case pilotCertification
|
case pilotCertification
|
||||||
|
case scenicPromotion
|
||||||
case modulePlaceholder(uri: String, title: String)
|
case modulePlaceholder(uri: String, title: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,7 @@ enum HomeMenuRouter {
|
|||||||
"project_edit": "项目管理",
|
"project_edit": "项目管理",
|
||||||
"location_report": "位置上报",
|
"location_report": "位置上报",
|
||||||
"report_photographer": "举报摄影师",
|
"report_photographer": "举报摄影师",
|
||||||
|
"promotion_page": "宣传页",
|
||||||
"registration_invitation": "注册邀请",
|
"registration_invitation": "注册邀请",
|
||||||
"store": "店铺管理",
|
"store": "店铺管理",
|
||||||
"fly": "飞行管理",
|
"fly": "飞行管理",
|
||||||
@ -134,6 +135,8 @@ enum HomeMenuRouter {
|
|||||||
return .destination(.locationReportHistory)
|
return .destination(.locationReportHistory)
|
||||||
case "report_photographer":
|
case "report_photographer":
|
||||||
return .destination(.photographerReport)
|
return .destination(.photographerReport)
|
||||||
|
case "promotion_page":
|
||||||
|
return .destination(.scenicPromotion)
|
||||||
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
|
case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info":
|
||||||
return .destination(.depositOrders)
|
return .destination(.depositOrders)
|
||||||
case "withdrawal_audit":
|
case "withdrawal_audit":
|
||||||
@ -220,6 +223,7 @@ enum HomeMenuRouter {
|
|||||||
"project_edit",
|
"project_edit",
|
||||||
"location_report",
|
"location_report",
|
||||||
"report_photographer",
|
"report_photographer",
|
||||||
|
"promotion_page",
|
||||||
"registration_invitation",
|
"registration_invitation",
|
||||||
"photographer_invite",
|
"photographer_invite",
|
||||||
"invite_record",
|
"invite_record",
|
||||||
@ -300,6 +304,8 @@ enum HomeMenuRouter {
|
|||||||
return "位置上报"
|
return "位置上报"
|
||||||
case "report_photographer":
|
case "report_photographer":
|
||||||
return "举报摄影师"
|
return "举报摄影师"
|
||||||
|
case "promotion_page":
|
||||||
|
return "宣传页"
|
||||||
case "pm", "project_edit":
|
case "pm", "project_edit":
|
||||||
return "项目管理"
|
return "项目管理"
|
||||||
case "pm_manager":
|
case "pm_manager":
|
||||||
|
|||||||
@ -31,6 +31,7 @@ struct HomeCommonMenuStore {
|
|||||||
"pm",
|
"pm",
|
||||||
"location_report",
|
"location_report",
|
||||||
"report_photographer",
|
"report_photographer",
|
||||||
|
"promotion_page",
|
||||||
"registration_invitation",
|
"registration_invitation",
|
||||||
"store",
|
"store",
|
||||||
"fly",
|
"fly",
|
||||||
|
|||||||
@ -56,6 +56,8 @@ enum HomeIconCatalog {
|
|||||||
"mappin.circle.fill"
|
"mappin.circle.fill"
|
||||||
case "report_photographer":
|
case "report_photographer":
|
||||||
"shield.fill"
|
"shield.fill"
|
||||||
|
case "promotion_page":
|
||||||
|
"rectangle.on.rectangle.angled"
|
||||||
case "registration_invitation", "photographer_invite":
|
case "registration_invitation", "photographer_invite":
|
||||||
"envelope.open.fill"
|
"envelope.open.fill"
|
||||||
case "store":
|
case "store":
|
||||||
|
|||||||
@ -112,6 +112,8 @@ extension HomeRoute {
|
|||||||
OperatingAreaView()
|
OperatingAreaView()
|
||||||
case .pilotCertification:
|
case .pilotCertification:
|
||||||
PilotCertificationView()
|
PilotCertificationView()
|
||||||
|
case .scenicPromotion:
|
||||||
|
ScenicPromotionView()
|
||||||
case let .modulePlaceholder(uri, title):
|
case let .modulePlaceholder(uri, title):
|
||||||
HomeMigrationModuleView(title: title, uri: uri)
|
HomeMigrationModuleView(title: title, uri: uri)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,215 @@
|
|||||||
|
//
|
||||||
|
// ScenicPromotionModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 宣传页页面状态
|
||||||
|
|
||||||
|
/// 宣传页主页面加载与展示状态。
|
||||||
|
enum ScenicPromotionPageState: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case normal
|
||||||
|
case empty
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 宣传页演示场景
|
||||||
|
|
||||||
|
/// 宣传页产品演示场景,用于验收 Loading / Empty / Error 等状态。
|
||||||
|
enum ScenicPromotionDemoScenario: String, CaseIterable, Identifiable {
|
||||||
|
case normal
|
||||||
|
case noMedia
|
||||||
|
case noQueue
|
||||||
|
case loading
|
||||||
|
case error
|
||||||
|
case empty
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .normal: return "正常"
|
||||||
|
case .noMedia: return "无媒体"
|
||||||
|
case .noQueue: return "无排队"
|
||||||
|
case .loading: return "加载"
|
||||||
|
case .error: return "异常"
|
||||||
|
case .empty: return "空数据"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 宣传页素材类型
|
||||||
|
|
||||||
|
/// 宣传页横向素材与主视觉区支持的视频 / 照片模式。
|
||||||
|
enum ScenicPromotionMediaMode: String, CaseIterable, Identifiable {
|
||||||
|
case video
|
||||||
|
case photo
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .video: return "视频"
|
||||||
|
case .photo: return "照片"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .video: return "play.circle.fill"
|
||||||
|
case .photo: return "photo.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 宣传页设置页状态
|
||||||
|
|
||||||
|
/// 宣传页设置页加载与展示状态。
|
||||||
|
enum ScenicPromotionSettingsPageState: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case normal
|
||||||
|
case empty
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 宣传页设置页演示场景
|
||||||
|
|
||||||
|
/// 设置页产品演示场景。
|
||||||
|
enum ScenicPromotionSettingsDemoScenario: String, CaseIterable, Identifiable {
|
||||||
|
case normal
|
||||||
|
case loading
|
||||||
|
case empty
|
||||||
|
case error
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .normal: return "正常"
|
||||||
|
case .loading: return "加载"
|
||||||
|
case .empty: return "空数据"
|
||||||
|
case .error: return "异常"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 设置页上传素材类型
|
||||||
|
|
||||||
|
/// 设置页中视频宣传素材与图片素材的上传类型。
|
||||||
|
enum ScenicPromotionUploadKind: String, CaseIterable, Identifiable {
|
||||||
|
case video
|
||||||
|
case image
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .video: return "视频宣传素材"
|
||||||
|
case .image: return "图片素材"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .video: return "视频"
|
||||||
|
case .image: return "图片"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .video: return "上传视频宣传素材"
|
||||||
|
case .image: return "上传图片素材"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var emptyTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .video: return "暂无视频宣传素材"
|
||||||
|
case .image: return "暂无图片素材"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .video: return "video.fill"
|
||||||
|
case .image: return "photo.on.rectangle.angled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 宣传页数据模型
|
||||||
|
|
||||||
|
/// 宣传页整块看板数据,后续接接口时可直接替换 Mock 来源。
|
||||||
|
struct ScenicPromotionBoard: Equatable {
|
||||||
|
let scenicName: String
|
||||||
|
let subtitle: String
|
||||||
|
let qrCodeTitle: String
|
||||||
|
let spots: [ScenicPromotionSpot]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个打卡点,包含排队数据和该点位的视频 / 图片素材。
|
||||||
|
struct ScenicPromotionSpot: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let iconName: String
|
||||||
|
let queue: ScenicPromotionQueueInfo?
|
||||||
|
let videos: [ScenicPromotionMedia]
|
||||||
|
let photos: [ScenicPromotionMedia]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前点位的排队和叫号信息。
|
||||||
|
struct ScenicPromotionQueueInfo: Equatable {
|
||||||
|
let waitingPeople: Int
|
||||||
|
let estimatedMinutes: Int
|
||||||
|
let averageMinutes: Int
|
||||||
|
let nextNumber: String
|
||||||
|
let currentNumber: String
|
||||||
|
let shootingDuration: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宣传页横向素材和底部主视觉共用的数据结构。
|
||||||
|
struct ScenicPromotionMedia: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let mode: ScenicPromotionMediaMode
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let duration: String?
|
||||||
|
let palette: [UInt]
|
||||||
|
let assetName: String?
|
||||||
|
let heroAssetName: String?
|
||||||
|
let previewWidth: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置页中的排队打卡点,多选绑定使用。
|
||||||
|
struct ScenicPromotionQueuePoint: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let areaName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置页中的素材打卡点,上传视频 / 图片前必须先选择。
|
||||||
|
struct ScenicPromotionMaterialPoint: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let areaName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置页中已上传的 Mock 素材,按素材打卡点分组管理。
|
||||||
|
struct ScenicPromotionManagedMaterial: Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let pointID: String
|
||||||
|
let kind: ScenicPromotionUploadKind
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let assetName: String
|
||||||
|
let duration: String?
|
||||||
|
let createdAt: String
|
||||||
|
}
|
||||||
79
suixinkan/Features/ScenicPromotion/ScenicPromotion.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# ScenicPromotion 模块业务逻辑
|
||||||
|
|
||||||
|
## 模块职责
|
||||||
|
|
||||||
|
ScenicPromotion 模块负责首页 `promotion_page` 入口,为摄影师和景区运营提供景区宣传与排队导览 Demo 页面。
|
||||||
|
|
||||||
|
当前阶段使用 Mock 数据完成业务闭环,不接入真实后端接口、真实视频地址或真实二维码。
|
||||||
|
|
||||||
|
## 页面流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Home[宣传页主页面] -->|QR 三连击 + 密码 1| Settings[宣传页设置]
|
||||||
|
Home -->|缩略图点击| Hero[底部主视觉联动]
|
||||||
|
Settings --> Preview[素材预览]
|
||||||
|
Settings -->|Mock 上传/删除| Settings
|
||||||
|
Settings -->|提交| Submit[Mock 提交结果]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心实体
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `ScenicPromotionBoard` | 宣传页看板:景区名、副标题、打卡点列表 |
|
||||||
|
| `ScenicPromotionSpot` | 单个打卡点:排队信息 + 视频/照片素材 |
|
||||||
|
| `ScenicPromotionQueueInfo` | 排队人数、等待时间、叫号信息 |
|
||||||
|
| `ScenicPromotionMedia` | 横向素材与主视觉共用数据 |
|
||||||
|
| `ScenicPromotionQueuePoint` | 设置页排队打卡点(多选绑定) |
|
||||||
|
| `ScenicPromotionMaterialPoint` | 设置页素材所属打卡点(单选,与排队点独立) |
|
||||||
|
| `ScenicPromotionManagedMaterial` | 设置页已上传 Mock 素材 |
|
||||||
|
|
||||||
|
## 数据来源
|
||||||
|
|
||||||
|
- 全部 Mock 数据由 `ScenicPromotionMockStore` 提供种子数据。
|
||||||
|
- `ScenicPromotionViewModel` 驱动主页面状态与素材选中。
|
||||||
|
- `ScenicPromotionSettingsViewModel` 独立管理设置页排队绑定与素材上传。
|
||||||
|
|
||||||
|
## 主页面规则
|
||||||
|
|
||||||
|
1. 打卡点由接口返回列表动态展示;Mock 含网红桥、汉地拔冲、空中草原、河谷花海、雪山栈道。
|
||||||
|
2. 5 个以内等分展示,超过 5 个时一屏露出 5 个并横向滚动。
|
||||||
|
3. 缩略图点击只联动底部主视觉,不跳转二级页。
|
||||||
|
4. 视频主视觉点击仅展示模拟播放提示。
|
||||||
|
5. 二维码快速点击 3 次(1.1 秒内)→ 密码弹窗 → 输入 `1` 进入设置页。
|
||||||
|
6. 长按二维码可切换演示状态(正常 / 无媒体 / 无排队 / 加载 / 异常 / 空数据)。
|
||||||
|
|
||||||
|
## 设置页规则
|
||||||
|
|
||||||
|
1. 排队打卡点支持多选绑定;提交前至少选一个。
|
||||||
|
2. 素材所属打卡点与排队打卡点是两套独立数据。
|
||||||
|
3. 视频/图片上传前必须先选择素材所属打卡点。
|
||||||
|
4. 上传、删除、提交均为 Mock 本地状态变更。
|
||||||
|
|
||||||
|
## 路由
|
||||||
|
|
||||||
|
- 权限 URI:`promotion_page`
|
||||||
|
- 首页路由:`HomeRoute.scenicPromotion`
|
||||||
|
- 入口依赖权限下发或用户手动添加到常用应用(`HomeCommonMenuStore.androidHomeMenuURIs` 白名单已包含该 URI)。
|
||||||
|
|
||||||
|
## 与相邻模块的边界
|
||||||
|
|
||||||
|
| 模块 | URI | 用途 | 后端 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ScenicPromotion | `promotion_page` | 景区宣传与排队导览 Demo | Mock |
|
||||||
|
| QueueManagement | `queue_management` | 真实排队叫号管理 | 真实 API |
|
||||||
|
| Assets | `asset_management` | 素材库管理 | 真实 API |
|
||||||
|
|
||||||
|
## 测试要求
|
||||||
|
|
||||||
|
- `ScenicPromotionTests`:主页面与设置页 ViewModel 成功路径、场景切换、上传校验。
|
||||||
|
- `HomeMenuRouterTests`:`promotion_page` 路由映射。
|
||||||
|
|
||||||
|
## 主要文件
|
||||||
|
|
||||||
|
- `Models/ScenicPromotionModels.swift` — 状态枚举与数据实体
|
||||||
|
- `Services/ScenicPromotionMockStore.swift` — Mock 种子数据
|
||||||
|
- `ViewModels/ScenicPromotionViewModels.swift` — 主页面与设置页 ViewModel
|
||||||
|
- `Views/ScenicPromotionViews.swift` — 主页面
|
||||||
|
- `Views/ScenicPromotionSettingsViews.swift` — 设置页与素材预览
|
||||||
@ -0,0 +1,360 @@
|
|||||||
|
//
|
||||||
|
// ScenicPromotionMockStore.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 宣传页 Mock 数据存储,当前阶段提供演示看板与设置页种子数据。
|
||||||
|
enum ScenicPromotionMockStore {
|
||||||
|
/// 宣传页主看板 Mock 数据。
|
||||||
|
static let mockBoard = ScenicPromotionBoard(
|
||||||
|
scenicName: "那拉提景区",
|
||||||
|
subtitle: "实时查看打卡点排队信息",
|
||||||
|
qrCodeTitle: "扫码查看排队导览",
|
||||||
|
spots: [
|
||||||
|
ScenicPromotionSpot(
|
||||||
|
id: "bridge",
|
||||||
|
name: "网红桥",
|
||||||
|
iconName: "bridge.2",
|
||||||
|
queue: ScenicPromotionQueueInfo(
|
||||||
|
waitingPeople: 54,
|
||||||
|
estimatedMinutes: 18,
|
||||||
|
averageMinutes: 16,
|
||||||
|
nextNumber: "B055",
|
||||||
|
currentNumber: "B054",
|
||||||
|
shootingDuration: "10分钟30秒"
|
||||||
|
),
|
||||||
|
videos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "bridge_video_1",
|
||||||
|
mode: .video,
|
||||||
|
title: "网红桥",
|
||||||
|
subtitle: "雪山下的蓝色木桥,旅拍打卡热门点",
|
||||||
|
duration: "00:32",
|
||||||
|
palette: [0x1D8BFF, 0x8FD7FF, 0x2F8F45],
|
||||||
|
assetName: "ScenicPromotionThumbPortrait",
|
||||||
|
heroAssetName: "ScenicPromotionHero",
|
||||||
|
previewWidth: 72
|
||||||
|
),
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "bridge_video_2",
|
||||||
|
mode: .video,
|
||||||
|
title: "汉地拔冲",
|
||||||
|
subtitle: "雪山草甸与观景石同框",
|
||||||
|
duration: "00:28",
|
||||||
|
palette: [0x2A7BEF, 0xB7E5FF, 0x74B84A],
|
||||||
|
assetName: "ScenicPromotionThumbMarker",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 144
|
||||||
|
),
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "bridge_video_3",
|
||||||
|
mode: .video,
|
||||||
|
title: "草甸旅拍",
|
||||||
|
subtitle: "绿色草坡上的打卡瞬间",
|
||||||
|
duration: "00:22",
|
||||||
|
palette: [0x2A7BEF, 0xB7E5FF, 0x74B84A],
|
||||||
|
assetName: "ScenicPromotionThumbGrass",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 72
|
||||||
|
),
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "bridge_video_4",
|
||||||
|
mode: .video,
|
||||||
|
title: "骑马路线",
|
||||||
|
subtitle: "游客骑行与雪山远景",
|
||||||
|
duration: "00:24",
|
||||||
|
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
|
||||||
|
assetName: "ScenicPromotionThumbHorse",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 72
|
||||||
|
)
|
||||||
|
],
|
||||||
|
photos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "bridge_photo_1",
|
||||||
|
mode: .photo,
|
||||||
|
title: "桥边人像",
|
||||||
|
subtitle: "白裙、花束、雪山背景",
|
||||||
|
duration: nil,
|
||||||
|
palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C],
|
||||||
|
assetName: "ScenicPromotionThumbPortrait",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 72
|
||||||
|
),
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "bridge_photo_2",
|
||||||
|
mode: .photo,
|
||||||
|
title: "全景草甸",
|
||||||
|
subtitle: "高山草甸与云层层次",
|
||||||
|
duration: nil,
|
||||||
|
palette: [0x3CA7FF, 0xC8F1FF, 0x4AAE57],
|
||||||
|
assetName: "ScenicPromotionThumbMarker",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 144
|
||||||
|
),
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "bridge_photo_3",
|
||||||
|
mode: .photo,
|
||||||
|
title: "骑马路线",
|
||||||
|
subtitle: "游客骑行与雪山远景",
|
||||||
|
duration: nil,
|
||||||
|
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
|
||||||
|
assetName: "ScenicPromotionThumbHorse",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 72
|
||||||
|
),
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "bridge_photo_4",
|
||||||
|
mode: .photo,
|
||||||
|
title: "草甸旅拍",
|
||||||
|
subtitle: "绿色草坡上的打卡瞬间",
|
||||||
|
duration: nil,
|
||||||
|
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
|
||||||
|
assetName: "ScenicPromotionThumbGrass",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 72
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
ScenicPromotionSpot(
|
||||||
|
id: "hendi",
|
||||||
|
name: "汉地拔冲",
|
||||||
|
iconName: "mountain.2.fill",
|
||||||
|
queue: ScenicPromotionQueueInfo(
|
||||||
|
waitingPeople: 32,
|
||||||
|
estimatedMinutes: 12,
|
||||||
|
averageMinutes: 14,
|
||||||
|
nextNumber: "H036",
|
||||||
|
currentNumber: "H035",
|
||||||
|
shootingDuration: "8分钟20秒"
|
||||||
|
),
|
||||||
|
videos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "hendi_video_1",
|
||||||
|
mode: .video,
|
||||||
|
title: "汉地拔冲",
|
||||||
|
subtitle: "雪山下的绿色画卷,旅拍打卡圣地",
|
||||||
|
duration: "00:32",
|
||||||
|
palette: [0x0B78F6, 0xB4E8FF, 0x4E9D3B],
|
||||||
|
assetName: "ScenicPromotionThumbMarker",
|
||||||
|
heroAssetName: "ScenicPromotionHero",
|
||||||
|
previewWidth: 144
|
||||||
|
)
|
||||||
|
],
|
||||||
|
photos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "hendi_photo_1",
|
||||||
|
mode: .photo,
|
||||||
|
title: "雪山草原",
|
||||||
|
subtitle: "开阔草原与雪山远景",
|
||||||
|
duration: nil,
|
||||||
|
palette: [0x167CFF, 0xBDEBFF, 0x58B15D],
|
||||||
|
assetName: "ScenicPromotionThumbGrass",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 72
|
||||||
|
),
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "hendi_photo_2",
|
||||||
|
mode: .photo,
|
||||||
|
title: "观景石",
|
||||||
|
subtitle: "点位标识与远山构图",
|
||||||
|
duration: nil,
|
||||||
|
palette: [0x1D6BD8, 0xA7DBFF, 0x769A5A],
|
||||||
|
assetName: "ScenicPromotionThumbMarker",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 144
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
ScenicPromotionSpot(
|
||||||
|
id: "grassland",
|
||||||
|
name: "空中草原",
|
||||||
|
iconName: "leaf.fill",
|
||||||
|
queue: ScenicPromotionQueueInfo(
|
||||||
|
waitingPeople: 21,
|
||||||
|
estimatedMinutes: 9,
|
||||||
|
averageMinutes: 13,
|
||||||
|
nextNumber: "G022",
|
||||||
|
currentNumber: "G021",
|
||||||
|
shootingDuration: "7分钟40秒"
|
||||||
|
),
|
||||||
|
videos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "grassland_video_1",
|
||||||
|
mode: .video,
|
||||||
|
title: "空中草原",
|
||||||
|
subtitle: "草甸花海与远山同框",
|
||||||
|
duration: "00:26",
|
||||||
|
palette: [0x2A7BEF, 0xB7E5FF, 0x74B84A],
|
||||||
|
assetName: "ScenicPromotionThumbGrass",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 96
|
||||||
|
)
|
||||||
|
],
|
||||||
|
photos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "grassland_photo_1",
|
||||||
|
mode: .photo,
|
||||||
|
title: "草原花海",
|
||||||
|
subtitle: "绿色草坡上的花海样片",
|
||||||
|
duration: nil,
|
||||||
|
palette: [0x3CA7FF, 0xC8F1FF, 0x4AAE57],
|
||||||
|
assetName: "ScenicPromotionThumbGrass",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 96
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
ScenicPromotionSpot(
|
||||||
|
id: "valley",
|
||||||
|
name: "河谷花海",
|
||||||
|
iconName: "camera.macro",
|
||||||
|
queue: ScenicPromotionQueueInfo(
|
||||||
|
waitingPeople: 18,
|
||||||
|
estimatedMinutes: 8,
|
||||||
|
averageMinutes: 12,
|
||||||
|
nextNumber: "V019",
|
||||||
|
currentNumber: "V018",
|
||||||
|
shootingDuration: "6分钟50秒"
|
||||||
|
),
|
||||||
|
videos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "valley_video_1",
|
||||||
|
mode: .video,
|
||||||
|
title: "河谷花海",
|
||||||
|
subtitle: "花海步道与雪山远景",
|
||||||
|
duration: "00:24",
|
||||||
|
palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C],
|
||||||
|
assetName: "ScenicPromotionThumbPortrait",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 96
|
||||||
|
)
|
||||||
|
],
|
||||||
|
photos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "valley_photo_1",
|
||||||
|
mode: .photo,
|
||||||
|
title: "花海人像",
|
||||||
|
subtitle: "花海前景与游客人像",
|
||||||
|
duration: nil,
|
||||||
|
palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C],
|
||||||
|
assetName: "ScenicPromotionThumbPortrait",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 96
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
ScenicPromotionSpot(
|
||||||
|
id: "snowtrail",
|
||||||
|
name: "雪山栈道",
|
||||||
|
iconName: "snowflake",
|
||||||
|
queue: ScenicPromotionQueueInfo(
|
||||||
|
waitingPeople: 27,
|
||||||
|
estimatedMinutes: 11,
|
||||||
|
averageMinutes: 15,
|
||||||
|
nextNumber: "S028",
|
||||||
|
currentNumber: "S027",
|
||||||
|
shootingDuration: "9分钟10秒"
|
||||||
|
),
|
||||||
|
videos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "snowtrail_video_1",
|
||||||
|
mode: .video,
|
||||||
|
title: "雪山栈道",
|
||||||
|
subtitle: "木栈道与雪山背景",
|
||||||
|
duration: "00:29",
|
||||||
|
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
|
||||||
|
assetName: "ScenicPromotionThumbHorse",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 96
|
||||||
|
)
|
||||||
|
],
|
||||||
|
photos: [
|
||||||
|
ScenicPromotionMedia(
|
||||||
|
id: "snowtrail_photo_1",
|
||||||
|
mode: .photo,
|
||||||
|
title: "栈道远眺",
|
||||||
|
subtitle: "雪山、栈道、游客同框",
|
||||||
|
duration: nil,
|
||||||
|
palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D],
|
||||||
|
assetName: "ScenicPromotionThumbHorse",
|
||||||
|
heroAssetName: nil,
|
||||||
|
previewWidth: 96
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
/// 设置页排队打卡点 Mock 数据。
|
||||||
|
static let mockQueuePoints: [ScenicPromotionQueuePoint] = [
|
||||||
|
ScenicPromotionQueuePoint(id: "queue_bridge", name: "网红桥排队点", areaName: "那拉提景区"),
|
||||||
|
ScenicPromotionQueuePoint(id: "queue_hendi", name: "汉地拔冲排队点", areaName: "那拉提景区"),
|
||||||
|
ScenicPromotionQueuePoint(id: "queue_grass", name: "草甸旅拍排队点", areaName: "那拉提景区")
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 设置页素材打卡点 Mock 数据。
|
||||||
|
static let mockMaterialPoints: [ScenicPromotionMaterialPoint] = [
|
||||||
|
ScenicPromotionMaterialPoint(id: "material_bridge", name: "网红桥", areaName: "宣传素材点"),
|
||||||
|
ScenicPromotionMaterialPoint(id: "material_hendi", name: "汉地拔冲", areaName: "宣传素材点"),
|
||||||
|
ScenicPromotionMaterialPoint(id: "material_grass", name: "空中草原", areaName: "宣传素材点")
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 设置页按素材点分组的已上传素材 Mock 数据。
|
||||||
|
static let mockMaterialsByPointID: [String: [ScenicPromotionManagedMaterial]] = [
|
||||||
|
"material_bridge": [
|
||||||
|
ScenicPromotionManagedMaterial(
|
||||||
|
id: "material_bridge_video_1",
|
||||||
|
pointID: "material_bridge",
|
||||||
|
kind: .video,
|
||||||
|
title: "网红桥视频1",
|
||||||
|
subtitle: "雪山草甸旅拍视频",
|
||||||
|
assetName: "ScenicPromotionHero",
|
||||||
|
duration: "00:32",
|
||||||
|
createdAt: "2026-06-25 18:20"
|
||||||
|
),
|
||||||
|
ScenicPromotionManagedMaterial(
|
||||||
|
id: "material_bridge_image_1",
|
||||||
|
pointID: "material_bridge",
|
||||||
|
kind: .image,
|
||||||
|
title: "网红桥图片1",
|
||||||
|
subtitle: "游客桥边人像样片",
|
||||||
|
assetName: "ScenicPromotionThumbPortrait",
|
||||||
|
duration: nil,
|
||||||
|
createdAt: "2026-06-25 18:24"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
"material_hendi": [
|
||||||
|
ScenicPromotionManagedMaterial(
|
||||||
|
id: "material_hendi_image_1",
|
||||||
|
pointID: "material_hendi",
|
||||||
|
kind: .image,
|
||||||
|
title: "汉地拔冲图片1",
|
||||||
|
subtitle: "观景石与雪山远景",
|
||||||
|
assetName: "ScenicPromotionThumbMarker",
|
||||||
|
duration: nil,
|
||||||
|
createdAt: "2026-06-25 18:30"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
/// 根据素材类型轮换本地 Mock 图片资源名。
|
||||||
|
static func assetName(for kind: ScenicPromotionUploadKind, index: Int) -> String {
|
||||||
|
switch kind {
|
||||||
|
case .video:
|
||||||
|
return index.isMultiple(of: 2) ? "ScenicPromotionThumbMarker" : "ScenicPromotionHero"
|
||||||
|
case .image:
|
||||||
|
let assets = [
|
||||||
|
"ScenicPromotionThumbPortrait",
|
||||||
|
"ScenicPromotionThumbGrass",
|
||||||
|
"ScenicPromotionThumbHorse",
|
||||||
|
"ScenicPromotionThumbMarker"
|
||||||
|
]
|
||||||
|
return assets[(index - 1) % assets.count]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,270 @@
|
|||||||
|
//
|
||||||
|
// ScenicPromotionViewModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 宣传页主视图 ViewModel,驱动看板数据、打卡点切换与素材选中状态。
|
||||||
|
@MainActor
|
||||||
|
final class ScenicPromotionViewModel: ObservableObject {
|
||||||
|
@Published private(set) var pageState: ScenicPromotionPageState = .idle
|
||||||
|
@Published private(set) var board: ScenicPromotionBoard?
|
||||||
|
@Published var selectedSpotID: String?
|
||||||
|
@Published var selectedMode: ScenicPromotionMediaMode = .video
|
||||||
|
@Published var selectedMediaID: ScenicPromotionMedia.ID?
|
||||||
|
@Published private(set) var activeScenario: ScenicPromotionDemoScenario = .normal
|
||||||
|
|
||||||
|
/// 当前选中的打卡点;如果 selectedSpotID 为空,默认取第一个点位。
|
||||||
|
var selectedSpot: ScenicPromotionSpot? {
|
||||||
|
guard let board else { return nil }
|
||||||
|
return board.spots.first { $0.id == selectedSpotID } ?? board.spots.first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无排队演示状态下返回 nil,让页面展示“当前暂无排队信息”。
|
||||||
|
var queue: ScenicPromotionQueueInfo? {
|
||||||
|
guard activeScenario != .noQueue else { return nil }
|
||||||
|
return selectedSpot?.queue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前素材列表随“视频 / 照片”切换变化,缩略图和主视觉共用这组数据。
|
||||||
|
var mediaItems: [ScenicPromotionMedia] {
|
||||||
|
guard activeScenario != .noMedia else { return [] }
|
||||||
|
return Self.mediaItems(for: selectedSpot, mode: selectedMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前主视觉素材;如果选中 ID 丢失,兜底展示列表第一条。
|
||||||
|
var selectedMedia: ScenicPromotionMedia? {
|
||||||
|
let items = mediaItems
|
||||||
|
return items.first { $0.id == selectedMediaID } ?? items.first
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasQueueData: Bool {
|
||||||
|
queue != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次进入页面时加载 Mock 数据。
|
||||||
|
func loadIfNeeded() async {
|
||||||
|
guard pageState == .idle else { return }
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟接口加载耗时,便于展示 Loading 状态。
|
||||||
|
func load() async {
|
||||||
|
withAnimation(.easeInOut(duration: 0.25)) {
|
||||||
|
pageState = .loading
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
applyScenario(.normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换产品演示状态,覆盖正常、无媒体、无排队、加载、异常和空数据。
|
||||||
|
func applyScenario(_ scenario: ScenicPromotionDemoScenario) {
|
||||||
|
activeScenario = scenario
|
||||||
|
withAnimation(.easeInOut(duration: 0.28)) {
|
||||||
|
switch scenario {
|
||||||
|
case .normal, .noMedia, .noQueue:
|
||||||
|
board = ScenicPromotionMockStore.mockBoard
|
||||||
|
selectedSpotID = selectedSpotID ?? board?.spots.first?.id
|
||||||
|
selectedMediaID = Self.mediaItems(for: selectedSpot, mode: selectedMode).first?.id
|
||||||
|
pageState = .normal
|
||||||
|
case .loading:
|
||||||
|
pageState = .loading
|
||||||
|
case .error:
|
||||||
|
board = nil
|
||||||
|
pageState = .error("宣传页数据加载失败,请稍后重试")
|
||||||
|
case .empty:
|
||||||
|
board = nil
|
||||||
|
pageState = .empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换打卡点时,默认选中当前素材类型下的第一条素材。
|
||||||
|
func selectSpot(_ spot: ScenicPromotionSpot) {
|
||||||
|
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
||||||
|
selectedSpotID = spot.id
|
||||||
|
selectedMediaID = Self.mediaItems(for: spot, mode: selectedMode).first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换视频 / 照片时,底部主视觉同步切到该模式下第一条素材。
|
||||||
|
func selectMode(_ mode: ScenicPromotionMediaMode) {
|
||||||
|
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
||||||
|
selectedMode = mode
|
||||||
|
selectedMediaID = Self.mediaItems(for: selectedSpot, mode: mode).first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 点击横向素材缩略图,只更新选中态并联动底部主视觉。
|
||||||
|
func selectMedia(_ media: ScenicPromotionMedia) {
|
||||||
|
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
||||||
|
selectedMediaID = media.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 左右箭头循环切换当前素材。
|
||||||
|
func moveMedia(_ offset: Int) {
|
||||||
|
let items = mediaItems
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
let currentIndex = items.firstIndex { $0.id == selectedMedia?.id } ?? 0
|
||||||
|
let nextIndex = (currentIndex + offset + items.count) % items.count
|
||||||
|
selectMedia(items[nextIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据点位和素材类型取素材列表,避免切换模式时读到旧模式数据。
|
||||||
|
static func mediaItems(
|
||||||
|
for spot: ScenicPromotionSpot?,
|
||||||
|
mode: ScenicPromotionMediaMode
|
||||||
|
) -> [ScenicPromotionMedia] {
|
||||||
|
switch mode {
|
||||||
|
case .video:
|
||||||
|
return spot?.videos ?? []
|
||||||
|
case .photo:
|
||||||
|
return spot?.photos ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宣传页设置 ViewModel,管理排队绑定、素材点、上传、删除和提交。
|
||||||
|
@MainActor
|
||||||
|
final class ScenicPromotionSettingsViewModel: ObservableObject {
|
||||||
|
@Published private(set) var pageState: ScenicPromotionSettingsPageState = .idle
|
||||||
|
@Published private(set) var queuePoints: [ScenicPromotionQueuePoint] = []
|
||||||
|
@Published private(set) var materialPoints: [ScenicPromotionMaterialPoint] = []
|
||||||
|
@Published var selectedQueuePointIDs: Set<String> = []
|
||||||
|
@Published var selectedMaterialPointID: String?
|
||||||
|
@Published private(set) var materialsByPointID: [String: [ScenicPromotionManagedMaterial]] = [:]
|
||||||
|
|
||||||
|
/// 多选排队打卡点的展示文案。
|
||||||
|
var selectedQueuePointNames: String {
|
||||||
|
let names = queuePoints
|
||||||
|
.filter { selectedQueuePointIDs.contains($0.id) }
|
||||||
|
.map(\.name)
|
||||||
|
return names.isEmpty ? "请选择排队打卡点" : names.joined(separator: "、")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前素材上传所属打卡点。
|
||||||
|
var selectedMaterialPoint: ScenicPromotionMaterialPoint? {
|
||||||
|
guard let selectedMaterialPointID else { return nil }
|
||||||
|
return materialPoints.first { $0.id == selectedMaterialPointID }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材点选择框的展示文案。
|
||||||
|
var selectedMaterialPointName: String {
|
||||||
|
selectedMaterialPoint?.name ?? "请选择素材所属打卡点"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次进入设置页时加载 Mock 配置。
|
||||||
|
func loadIfNeeded() async {
|
||||||
|
guard pageState == .idle else { return }
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟设置页加载过程。
|
||||||
|
func load() async {
|
||||||
|
withAnimation(.easeInOut(duration: 0.24)) {
|
||||||
|
pageState = .loading
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 260_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
applyScenario(.normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换设置页演示状态。
|
||||||
|
func applyScenario(_ scenario: ScenicPromotionSettingsDemoScenario) {
|
||||||
|
withAnimation(.easeInOut(duration: 0.24)) {
|
||||||
|
switch scenario {
|
||||||
|
case .normal:
|
||||||
|
queuePoints = ScenicPromotionMockStore.mockQueuePoints
|
||||||
|
materialPoints = ScenicPromotionMockStore.mockMaterialPoints
|
||||||
|
selectedQueuePointIDs = ["queue_bridge", "queue_hendi"]
|
||||||
|
selectedMaterialPointID = selectedMaterialPointID ?? ScenicPromotionMockStore.mockMaterialPoints.first?.id
|
||||||
|
materialsByPointID = ScenicPromotionMockStore.mockMaterialsByPointID
|
||||||
|
pageState = .normal
|
||||||
|
case .loading:
|
||||||
|
pageState = .loading
|
||||||
|
case .empty:
|
||||||
|
queuePoints = []
|
||||||
|
materialPoints = []
|
||||||
|
selectedQueuePointIDs = []
|
||||||
|
selectedMaterialPointID = nil
|
||||||
|
materialsByPointID = [:]
|
||||||
|
pageState = .empty
|
||||||
|
case .error:
|
||||||
|
queuePoints = []
|
||||||
|
materialPoints = []
|
||||||
|
selectedQueuePointIDs = []
|
||||||
|
selectedMaterialPointID = nil
|
||||||
|
materialsByPointID = [:]
|
||||||
|
pageState = .error("宣传页设置数据加载失败,请稍后重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 排队打卡点多选逻辑。
|
||||||
|
func toggleQueuePoint(_ point: ScenicPromotionQueuePoint) {
|
||||||
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) {
|
||||||
|
if selectedQueuePointIDs.contains(point.id) {
|
||||||
|
selectedQueuePointIDs.remove(point.id)
|
||||||
|
} else {
|
||||||
|
selectedQueuePointIDs.insert(point.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换素材所属打卡点后,页面会展示该点位已有素材。
|
||||||
|
func selectMaterialPoint(_ point: ScenicPromotionMaterialPoint) {
|
||||||
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) {
|
||||||
|
selectedMaterialPointID = point.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前素材点下指定类型的素材。
|
||||||
|
func materials(for kind: ScenicPromotionUploadKind) -> [ScenicPromotionManagedMaterial] {
|
||||||
|
guard let selectedMaterialPointID else { return [] }
|
||||||
|
return (materialsByPointID[selectedMaterialPointID] ?? [])
|
||||||
|
.filter { $0.kind == kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 模拟上传素材;未选择素材点时返回 false,由页面弹提示。
|
||||||
|
func addMaterial(kind: ScenicPromotionUploadKind) -> Bool {
|
||||||
|
guard let point = selectedMaterialPoint else { return false }
|
||||||
|
let currentItems = materialsByPointID[point.id] ?? []
|
||||||
|
let nextIndex = currentItems.filter { $0.kind == kind }.count + 1
|
||||||
|
let assetName = ScenicPromotionMockStore.assetName(for: kind, index: nextIndex)
|
||||||
|
let material = ScenicPromotionManagedMaterial(
|
||||||
|
id: "\(point.id)_\(kind.rawValue)_\(UUID().uuidString)",
|
||||||
|
pointID: point.id,
|
||||||
|
kind: kind,
|
||||||
|
title: "\(point.name)\(kind.shortTitle)\(nextIndex)",
|
||||||
|
subtitle: kind == .video ? "模拟上传视频,待后续接入真实素材" : "模拟上传图片,待后续接入真实素材",
|
||||||
|
assetName: assetName,
|
||||||
|
duration: kind == .video ? "00:\(String(format: "%02d", 20 + nextIndex * 3))" : nil,
|
||||||
|
createdAt: "2026-06-25 19:\(String(format: "%02d", 20 + nextIndex))"
|
||||||
|
)
|
||||||
|
|
||||||
|
withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) {
|
||||||
|
materialsByPointID[point.id, default: []].append(material)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除当前素材点下的某个素材。
|
||||||
|
func deleteMaterial(_ material: ScenicPromotionManagedMaterial) {
|
||||||
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) {
|
||||||
|
materialsByPointID[material.pointID]?.removeAll { $0.id == material.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交前校验至少绑定一个排队打卡点。
|
||||||
|
func submit() -> Bool {
|
||||||
|
guard !selectedQueuePointIDs.isEmpty else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,598 @@
|
|||||||
|
//
|
||||||
|
// ScenicPromotionSettingsViews.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 宣传页设置页
|
||||||
|
|
||||||
|
struct ScenicPromotionSettingsView: View {
|
||||||
|
// 设置页独立 ViewModel,管理排队绑定、素材点、上传、删除和提交。
|
||||||
|
@StateObject private var viewModel = ScenicPromotionSettingsViewModel()
|
||||||
|
// 当前要预览的已上传素材。
|
||||||
|
@State private var previewMaterial: ScenicPromotionManagedMaterial?
|
||||||
|
// 上传前未选择素材点时的提示弹窗。
|
||||||
|
@State private var showSelectMaterialPointAlert = false
|
||||||
|
// 提交结果提示弹窗。
|
||||||
|
@State private var showSubmitAlert = false
|
||||||
|
@State private var submitMessage = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
AppDesign.background.ignoresSafeArea()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
switch viewModel.pageState {
|
||||||
|
case .idle, .loading:
|
||||||
|
settingsLoadingView
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||||
|
case .empty:
|
||||||
|
settingsFullStateView(
|
||||||
|
icon: "tray",
|
||||||
|
title: "暂无设置数据",
|
||||||
|
message: "当前没有可配置的排队打卡点和素材打卡点",
|
||||||
|
buttonTitle: "重新加载"
|
||||||
|
) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
case .error(let message):
|
||||||
|
settingsFullStateView(
|
||||||
|
icon: "wifi.exclamationmark",
|
||||||
|
title: "加载失败",
|
||||||
|
message: message,
|
||||||
|
buttonTitle: "重试"
|
||||||
|
) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
case .normal:
|
||||||
|
normalContent
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.24), value: viewModel.pageState)
|
||||||
|
}
|
||||||
|
.navigationTitle("宣传页设置")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.appNavigationDestination(item: $previewMaterial) { material in
|
||||||
|
ScenicPromotionMaterialPreviewView(material: material)
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
if case .normal = viewModel.pageState {
|
||||||
|
submitBar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
ForEach(ScenicPromotionSettingsDemoScenario.allCases) { scenario in
|
||||||
|
Button(scenario.title) {
|
||||||
|
viewModel.applyScenario(scenario)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
}
|
||||||
|
.accessibilityLabel("设置页演示状态")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("请选择素材打卡点", isPresented: $showSelectMaterialPointAlert) {
|
||||||
|
Button("知道了", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("视频宣传素材和图片素材上传前,需要先选择素材所属打卡点。")
|
||||||
|
}
|
||||||
|
.alert("提交结果", isPresented: $showSubmitAlert) {
|
||||||
|
Button("知道了", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(submitMessage)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置页正常内容区域。
|
||||||
|
private var normalContent: some View {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
settingsHeader
|
||||||
|
queueBindingCard
|
||||||
|
materialPointCard
|
||||||
|
materialUploadCard(kind: .video)
|
||||||
|
materialUploadCard(kind: .image)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 14)
|
||||||
|
.padding(.bottom, 28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置页顶部说明模块。
|
||||||
|
private var settingsHeader: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.system(size: 24, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 52, height: 52)
|
||||||
|
.background(.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("宣传页内容设置")
|
||||||
|
.font(.system(size: 20, weight: .heavy))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text("配置排队绑定、素材所属点位和本地演示素材")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.86))
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color(hex: 0x087BFF), Color(hex: 0x0C56D8)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
),
|
||||||
|
in: RoundedRectangle(cornerRadius: 18)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排队打卡点绑定,多选下拉。
|
||||||
|
private var queueBindingCard: some View {
|
||||||
|
settingsCard(
|
||||||
|
title: "排队打卡点绑定",
|
||||||
|
subtitle: "可多选,用于宣传页展示排队动态",
|
||||||
|
icon: "checklist.checked",
|
||||||
|
tint: AppDesign.primary
|
||||||
|
) {
|
||||||
|
Menu {
|
||||||
|
ForEach(viewModel.queuePoints) { point in
|
||||||
|
Button {
|
||||||
|
viewModel.toggleQueuePoint(point)
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
"\(point.name) · \(point.areaName)",
|
||||||
|
systemImage: viewModel.selectedQueuePointIDs.contains(point.id) ? "checkmark.circle.fill" : "circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
selectionField(
|
||||||
|
title: viewModel.selectedQueuePointNames,
|
||||||
|
subtitle: "\(viewModel.selectedQueuePointIDs.count) 个已选择",
|
||||||
|
icon: "chevron.down"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if viewModel.selectedQueuePointIDs.isEmpty {
|
||||||
|
inlineNotice("提交前至少选择一个排队打卡点", tint: AppDesign.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 素材所属打卡点选择,和排队打卡点是两套数据。
|
||||||
|
private var materialPointCard: some View {
|
||||||
|
settingsCard(
|
||||||
|
title: "素材所属打卡点",
|
||||||
|
subtitle: "视频和图片上传前必须先选择,和上方排队绑定点分开管理",
|
||||||
|
icon: "mappin.and.ellipse",
|
||||||
|
tint: Color(hex: 0x22A06B)
|
||||||
|
) {
|
||||||
|
Menu {
|
||||||
|
ForEach(viewModel.materialPoints) { point in
|
||||||
|
Button {
|
||||||
|
viewModel.selectMaterialPoint(point)
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
"\(point.name) · \(point.areaName)",
|
||||||
|
systemImage: viewModel.selectedMaterialPointID == point.id ? "checkmark.circle.fill" : "circle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
selectionField(
|
||||||
|
title: viewModel.selectedMaterialPointName,
|
||||||
|
subtitle: viewModel.selectedMaterialPoint == nil ? "未选择" : "已展示当前点位素材",
|
||||||
|
icon: "chevron.down"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
// 视频 / 图片上传模块,未选择素材点时不允许上传。
|
||||||
|
private func materialUploadCard(kind: ScenicPromotionUploadKind) -> some View {
|
||||||
|
let materials = viewModel.materials(for: kind)
|
||||||
|
|
||||||
|
settingsCard(
|
||||||
|
title: kind.title,
|
||||||
|
subtitle: viewModel.selectedMaterialPoint == nil ? "先选择素材所属打卡点后再上传" : "当前展示 \(viewModel.selectedMaterialPointName) 已上传素材",
|
||||||
|
icon: kind.iconName,
|
||||||
|
tint: kind == .video ? Color(hex: 0x7A4DFF) : AppDesign.primary
|
||||||
|
) {
|
||||||
|
HStack {
|
||||||
|
Text(viewModel.selectedMaterialPointName)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.78)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
upload(kind)
|
||||||
|
} label: {
|
||||||
|
Label(kind.uploadTitle, systemImage: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.frame(height: 34)
|
||||||
|
.background(
|
||||||
|
viewModel.selectedMaterialPoint == nil
|
||||||
|
? Color(hex: 0xAAB7C7)
|
||||||
|
: AppDesign.primary,
|
||||||
|
in: Capsule()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.selectedMaterialPoint == nil {
|
||||||
|
inlineNotice("请选择素材所属打卡点后再上传\(kind.shortTitle)", tint: AppDesign.warning)
|
||||||
|
} else if materials.isEmpty {
|
||||||
|
materialEmptyView(kind)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(materials) { material in
|
||||||
|
materialRow(material)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟上传素材,并把素材保存到当前素材点下面。
|
||||||
|
private func upload(_ kind: ScenicPromotionUploadKind) {
|
||||||
|
guard viewModel.addMaterial(kind: kind) else {
|
||||||
|
showSelectMaterialPointAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部悬浮提交按钮逻辑。
|
||||||
|
private func handleSubmit() {
|
||||||
|
if viewModel.submit() {
|
||||||
|
submitMessage = "宣传页设置已保存,当前为 Mock 演示数据。"
|
||||||
|
} else {
|
||||||
|
submitMessage = "请至少绑定一个排队打卡点。"
|
||||||
|
}
|
||||||
|
showSubmitAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置页通用卡片容器。
|
||||||
|
private func settingsCard<Content: View>(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
icon: String,
|
||||||
|
tint: Color,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 18, weight: .heavy))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.stroke(AppDesign.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下拉选择框样式。
|
||||||
|
private func selectionField(title: String, subtitle: String, icon: String) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.minimumScaleFactor(0.82)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.background(AppDesign.primarySoft, in: Circle())
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(Color(hex: 0xCFE1FF), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单内联提示,用于上传前置条件和提交校验提示。
|
||||||
|
private func inlineNotice(_ text: String, tint: Color) -> some View {
|
||||||
|
Label(text, systemImage: "exclamationmark.circle.fill")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某个素材点当前没有视频 / 图片素材时展示的空状态。
|
||||||
|
private func materialEmptyView(_ kind: ScenicPromotionUploadKind) -> some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: kind.iconName)
|
||||||
|
.font(.system(size: 28, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(width: 58, height: 58)
|
||||||
|
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
|
||||||
|
Text(kind.emptyTitle)
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Text("点击上传后会把素材保存到当前选择的打卡点")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
.background(Color(hex: 0xF8FAFD), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已上传素材行:点击预览,右侧按钮删除。
|
||||||
|
private func materialRow(_ material: ScenicPromotionManagedMaterial) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button {
|
||||||
|
previewMaterial = material
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
materialThumbnail(material)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(material.title)
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(material.subtitle)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(material.createdAt)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(Color(hex: 0x8A95A6))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(Color(hex: 0x9AA5B5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.deleteMaterial(material)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.danger)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(Color(hex: 0xFFF1F1), in: Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(Color(hex: 0xF8FAFD), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 素材行缩略图,视频额外展示播放标识。
|
||||||
|
private func materialThumbnail(_ material: ScenicPromotionManagedMaterial) -> some View {
|
||||||
|
ZStack {
|
||||||
|
Image(material.assetName)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 64, height: 48)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
|
||||||
|
if material.kind == .video {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.background(.black.opacity(0.42), in: Circle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置页 Loading 骨架屏。
|
||||||
|
private var settingsLoadingView: some View {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.fill(.white)
|
||||||
|
.frame(height: 92)
|
||||||
|
ForEach(0..<4, id: \.self) { _ in
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.fill(.white)
|
||||||
|
.frame(height: 132)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.redacted(reason: .placeholder)
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置页 Empty / Error 通用状态视图。
|
||||||
|
private func settingsFullStateView(
|
||||||
|
icon: String,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
buttonTitle: String,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 42, weight: .semibold))
|
||||||
|
.foregroundStyle(AppDesign.primary)
|
||||||
|
.frame(width: 82, height: 82)
|
||||||
|
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 22))
|
||||||
|
|
||||||
|
VStack(spacing: 7) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: action) {
|
||||||
|
Text(buttonTitle)
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 132, height: 42)
|
||||||
|
.background(AppDesign.primary, in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部悬浮提交栏,固定在安全区底部。
|
||||||
|
private var submitBar: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Divider()
|
||||||
|
Button {
|
||||||
|
handleSubmit()
|
||||||
|
} label: {
|
||||||
|
Text("提交设置")
|
||||||
|
.font(.system(size: 17, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 50)
|
||||||
|
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 设置页素材预览
|
||||||
|
|
||||||
|
struct ScenicPromotionMaterialPreviewView: View {
|
||||||
|
// 当前预览的 Mock 素材。
|
||||||
|
let material: ScenicPromotionManagedMaterial
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
ZStack {
|
||||||
|
Image(material.assetName)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(height: 280)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.clipped()
|
||||||
|
|
||||||
|
if material.kind == .video {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 34, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 76, height: 76)
|
||||||
|
.background(.black.opacity(0.36), in: Circle())
|
||||||
|
.overlay(Circle().stroke(.white, lineWidth: 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(material.title)
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
Text(material.subtitle)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
previewInfoRow(title: "素材类型", value: material.kind.shortTitle)
|
||||||
|
previewInfoRow(title: "上传时间", value: material.createdAt)
|
||||||
|
if let duration = material.duration {
|
||||||
|
previewInfoRow(title: "视频时长", value: duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
.background(AppDesign.background.ignoresSafeArea())
|
||||||
|
.navigationTitle(material.kind == .video ? "视频预览" : "图片预览")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览详情中的单行字段。
|
||||||
|
private func previewInfoRow(title: String, value: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(AppDesign.textSecondary)
|
||||||
|
Spacer()
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundStyle(AppDesign.textPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,789 @@
|
|||||||
|
//
|
||||||
|
// ScenicPromotionViews.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - 宣传页布局常量
|
||||||
|
|
||||||
|
private enum ScenicPromotionLayout {
|
||||||
|
// 主页面内容距离屏幕左右边缘各 20px,不代表模块之间的间距。
|
||||||
|
static let screenEdgePadding: CGFloat = 20
|
||||||
|
// 模块之间保持轻量垂直间距,避免误用 20px 做模块间隔。
|
||||||
|
static let moduleVerticalSpacing: CGFloat = 12
|
||||||
|
// 打卡点标签区最多一屏露出 5 个,更多时横向滚动。
|
||||||
|
static let visibleSpotTabCount: CGFloat = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 宣传页隐藏设置路由
|
||||||
|
|
||||||
|
private struct ScenicPromotionSettingsRoute: Identifiable, Hashable {
|
||||||
|
let id = "promotion_page_settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 宣传页主视图
|
||||||
|
|
||||||
|
struct ScenicPromotionView: View {
|
||||||
|
// 页面主数据和交互状态都收敛到 ViewModel,View 只负责布局和事件转发。
|
||||||
|
@StateObject private var viewModel = ScenicPromotionViewModel()
|
||||||
|
// 二维码三连击后进入设置页,使用轻量本地路由控制跳转。
|
||||||
|
@State private var settingsRoute: ScenicPromotionSettingsRoute?
|
||||||
|
// 记录二维码短时间点击次数,用于触发隐藏密码入口。
|
||||||
|
@State private var qrTapCount = 0
|
||||||
|
@State private var qrTapResetTask: Task<Void, Never>?
|
||||||
|
// 设置入口密码弹窗状态。
|
||||||
|
@State private var showPasswordPrompt = false
|
||||||
|
@State private var settingsPassword = ""
|
||||||
|
@State private var showPasswordError = false
|
||||||
|
// 长按二维码用于产品演示状态切换,不占用页面可见区域。
|
||||||
|
@State private var showDemoStateMenu = false
|
||||||
|
// 主视觉视频点击后展示模拟播放提示。
|
||||||
|
@State private var showPlayToast = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ScenicPromotionSkyBackground()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
switch viewModel.pageState {
|
||||||
|
case .idle, .loading:
|
||||||
|
loadingView
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||||
|
case .empty:
|
||||||
|
fullStateView(
|
||||||
|
icon: "doc.text.image",
|
||||||
|
title: "暂无宣传页数据",
|
||||||
|
message: "当前景区还没有配置排队导览宣传内容",
|
||||||
|
buttonTitle: "重新加载"
|
||||||
|
) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
case .error(let message):
|
||||||
|
fullStateView(
|
||||||
|
icon: "wifi.exclamationmark",
|
||||||
|
title: "加载失败",
|
||||||
|
message: message,
|
||||||
|
buttonTitle: "重试"
|
||||||
|
) {
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
case .normal:
|
||||||
|
contentView
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.28), value: viewModel.pageState)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(.container, edges: .top)
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.appNavigationDestination(item: $settingsRoute) { _ in
|
||||||
|
ScenicPromotionSettingsView()
|
||||||
|
}
|
||||||
|
.alert("输入管理密码", isPresented: $showPasswordPrompt) {
|
||||||
|
SecureField("请输入密码", text: $settingsPassword)
|
||||||
|
Button("取消", role: .cancel) {
|
||||||
|
settingsPassword = ""
|
||||||
|
}
|
||||||
|
Button("进入") {
|
||||||
|
verifySettingsPassword()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("连续点击二维码 3 次后可进入宣传页设置")
|
||||||
|
}
|
||||||
|
.alert("密码错误", isPresented: $showPasswordError) {
|
||||||
|
Button("知道了", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("请输入正确密码")
|
||||||
|
}
|
||||||
|
.alert("视频播放", isPresented: $showPlayToast) {
|
||||||
|
Button("知道了", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Demo 已模拟播放入口,后续接入真实视频地址即可播放。")
|
||||||
|
}
|
||||||
|
.confirmationDialog("演示状态", isPresented: $showDemoStateMenu, titleVisibility: .visible) {
|
||||||
|
ForEach(ScenicPromotionDemoScenario.allCases) { scenario in
|
||||||
|
Button(scenario.title) {
|
||||||
|
viewModel.applyScenario(scenario)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
qrTapResetTask?.cancel()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宣传页正常内容,从上到下还原参考图的信息层级。
|
||||||
|
private var contentView: some View {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: ScenicPromotionLayout.moduleVerticalSpacing) {
|
||||||
|
if let board = viewModel.board {
|
||||||
|
header(board)
|
||||||
|
spotTabs(board.spots)
|
||||||
|
metricsGrid
|
||||||
|
queuePanel
|
||||||
|
mediaToolbar
|
||||||
|
mediaStrip
|
||||||
|
heroMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, ScenicPromotionLayout.screenEdgePadding)
|
||||||
|
.padding(.top, 28)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶部标题、说明文案和二维码入口。
|
||||||
|
private func header(_ board: ScenicPromotionBoard) -> some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
VStack(spacing: 7) {
|
||||||
|
Text("\(board.scenicName) · 排队导览")
|
||||||
|
.font(.system(size: 26, weight: .heavy))
|
||||||
|
.foregroundStyle(Color(hex: 0x0E3F91))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.minimumScaleFactor(0.72)
|
||||||
|
.lineLimit(1)
|
||||||
|
.shadow(color: .white.opacity(0.55), radius: 0, x: 0, y: 2)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(hex: 0x2F80ED, alpha: 0.35))
|
||||||
|
.frame(width: 36, height: 1)
|
||||||
|
Text(board.subtitle)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Color(hex: 0x1D5DA8))
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(hex: 0x2F80ED, alpha: 0.35))
|
||||||
|
.frame(width: 36, height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.horizontal, 62)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
handleQRCodeTap()
|
||||||
|
} label: {
|
||||||
|
ScenicPromotionQRCodeView()
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.padding(5)
|
||||||
|
.background(.white, in: RoundedRectangle(cornerRadius: 9))
|
||||||
|
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.16), radius: 10, y: 4)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.simultaneousGesture(
|
||||||
|
LongPressGesture(minimumDuration: 0.7)
|
||||||
|
.onEnded { _ in
|
||||||
|
showDemoStateMenu = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.accessibilityLabel("二维码设置入口")
|
||||||
|
}
|
||||||
|
.frame(height: 96)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二维码快速点击 3 次后弹出设置密码输入框。
|
||||||
|
private func handleQRCodeTap() {
|
||||||
|
qrTapCount += 1
|
||||||
|
qrTapResetTask?.cancel()
|
||||||
|
|
||||||
|
if qrTapCount >= 3 {
|
||||||
|
qrTapCount = 0
|
||||||
|
settingsPassword = ""
|
||||||
|
showPasswordPrompt = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
qrTapResetTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_100_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
qrTapCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前 Mock 密码为 1,通过后进入宣传页设置页。
|
||||||
|
private func verifySettingsPassword() {
|
||||||
|
if settingsPassword == "1" {
|
||||||
|
settingsPassword = ""
|
||||||
|
settingsRoute = ScenicPromotionSettingsRoute()
|
||||||
|
} else {
|
||||||
|
settingsPassword = ""
|
||||||
|
showPasswordError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打卡点标签:接口返回少于 5 个时等分,超过 5 个时每屏露出 5 个并支持横向滚动。
|
||||||
|
@ViewBuilder
|
||||||
|
private func spotTabs(_ spots: [ScenicPromotionSpot]) -> some View {
|
||||||
|
if spots.isEmpty {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let visibleCount = min(CGFloat(max(spots.count, 1)), ScenicPromotionLayout.visibleSpotTabCount)
|
||||||
|
let tabWidth = proxy.size.width / visibleCount
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(spots) { spot in
|
||||||
|
spotTabButton(spot)
|
||||||
|
.frame(width: tabWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollDisabled(spots.count <= Int(ScenicPromotionLayout.visibleSpotTabCount))
|
||||||
|
}
|
||||||
|
.frame(height: 42)
|
||||||
|
.background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(.white.opacity(0.8), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.12), radius: 12, y: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个打卡点标签按钮,字号和图标压小后保证 5 个标签可以露出。
|
||||||
|
private func spotTabButton(_ spot: ScenicPromotionSpot) -> some View {
|
||||||
|
let isSelected = viewModel.selectedSpot?.id == spot.id
|
||||||
|
return Button {
|
||||||
|
viewModel.selectSpot(spot)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: spot.iconName)
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
Text(spot.name)
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.66)
|
||||||
|
}
|
||||||
|
.foregroundStyle(isSelected ? .white : Color(hex: 0x14213D))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 42)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.background(
|
||||||
|
Group {
|
||||||
|
if isSelected {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color(hex: 0x1286FF), Color(hex: 0x006BFF)],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 三个排队数据卡片:当前排队人数、预计等待时间、平均等待时长。
|
||||||
|
private var metricsGrid: some View {
|
||||||
|
let queue = viewModel.queue
|
||||||
|
let metrics: [(String, String, String, Color)] = [
|
||||||
|
("当前排队人数", queue.map { "\($0.waitingPeople)" } ?? "--", "人", Color(hex: 0x1580FF)),
|
||||||
|
("预计等待时间", queue.map { "\($0.estimatedMinutes)" } ?? "--", "分钟", Color(hex: 0xF28C18)),
|
||||||
|
("平均等待时长", queue.map { "\($0.averageMinutes)" } ?? "--", "分钟", Color(hex: 0x2D8E43))
|
||||||
|
]
|
||||||
|
|
||||||
|
return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) {
|
||||||
|
ForEach(metrics, id: \.0) { metric in
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(metric.0)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Color(hex: 0x113B79))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.76)
|
||||||
|
HStack(alignment: .lastTextBaseline, spacing: 2) {
|
||||||
|
Text(metric.1)
|
||||||
|
.font(.system(size: 34, weight: .heavy))
|
||||||
|
.foregroundStyle(metric.3)
|
||||||
|
.minimumScaleFactor(0.55)
|
||||||
|
if metric.1 != "--" {
|
||||||
|
Text(metric.2)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(metric.3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if metric.1 == "--" {
|
||||||
|
Text("暂无数据")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Color(hex: 0x5272A5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 92)
|
||||||
|
.background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(.white.opacity(0.85), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前叫号模块:有排队数据时展示叫号,无排队时展示空状态。
|
||||||
|
private var queuePanel: some View {
|
||||||
|
Group {
|
||||||
|
if let queue = viewModel.queue {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
queueSide(icon: "camera.aperture", title: "下一位", value: queue.nextNumber, tint: Color(hex: 0x197BFF))
|
||||||
|
Divider()
|
||||||
|
.frame(height: 52)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("当前叫号")
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
.foregroundStyle(Color(hex: 0x123F86))
|
||||||
|
Text(queue.currentNumber)
|
||||||
|
.font(.system(size: 38, weight: .heavy))
|
||||||
|
.foregroundStyle(Color(hex: 0x197BFF))
|
||||||
|
.minimumScaleFactor(0.55)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
Divider()
|
||||||
|
.frame(height: 52)
|
||||||
|
queueSide(icon: "person.2.fill", title: "拍摄用时", value: queue.shootingDuration, tint: Color(hex: 0x7A4DFF))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
Image(systemName: "list.clipboard")
|
||||||
|
.font(.system(size: 46, weight: .semibold))
|
||||||
|
.foregroundStyle(Color(hex: 0x2F80ED))
|
||||||
|
.frame(width: 92, height: 92)
|
||||||
|
.background(Color(hex: 0xEAF4FF), in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("当前暂无排队信息")
|
||||||
|
.font(.system(size: 22, weight: .heavy))
|
||||||
|
.foregroundStyle(Color(hex: 0x123F86))
|
||||||
|
Text("可先浏览景点样片,稍后再查看排队动态")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Color(hex: 0x5272A5))
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.white.opacity(0.86), in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.stroke(.white.opacity(0.9), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.10), radius: 14, y: 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 叫号模块左右两侧的小信息块,分别用于“下一位”和“拍摄用时”。
|
||||||
|
private func queueSide(icon: String, title: String, value: String, tint: Color) -> some View {
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.background(tint.opacity(0.13), in: Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(Color(hex: 0x113B79))
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 13, weight: .heavy))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.minimumScaleFactor(0.76)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频 / 照片切换工具条,点击后刷新横向素材和底部主视觉。
|
||||||
|
private var mediaToolbar: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(ScenicPromotionMediaMode.allCases) { mode in
|
||||||
|
Button {
|
||||||
|
viewModel.selectMode(mode)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: mode.iconName)
|
||||||
|
Text(mode.title)
|
||||||
|
}
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.foregroundStyle(viewModel.selectedMode == mode ? .white : Color(hex: 0x1B4D91))
|
||||||
|
.frame(width: 68, height: 34)
|
||||||
|
.background(
|
||||||
|
viewModel.selectedMode == mode
|
||||||
|
? Color(hex: 0x0B79FF)
|
||||||
|
: Color.clear
|
||||||
|
)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(3)
|
||||||
|
.background(.white.opacity(0.78), in: Capsule())
|
||||||
|
.overlay(Capsule().stroke(.white.opacity(0.9), lineWidth: 1))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Label("触屏切换视频/照片,左右滑动查看更多", systemImage: "hand.tap.fill")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Color(hex: 0x124E97))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.58)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 红框素材选择器:支持横向滚动、左右切换,点击缩略图只联动底部主视觉。
|
||||||
|
private var mediaStrip: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
viewModel.moveMedia(-1)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 22, weight: .heavy))
|
||||||
|
.foregroundStyle(Color(hex: 0x326EAE))
|
||||||
|
.frame(width: 28, height: 100)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if viewModel.mediaItems.isEmpty {
|
||||||
|
mediaEmptyPanel
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(viewModel.mediaItems) { media in
|
||||||
|
Button {
|
||||||
|
viewModel.selectMedia(media)
|
||||||
|
} label: {
|
||||||
|
ScenicPromotionMediaArtwork(media: media, compact: true, useHeroAsset: false)
|
||||||
|
.frame(width: media.previewWidth, height: 66)
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
if let duration = media.duration {
|
||||||
|
Text(duration)
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.black.opacity(0.42), in: Capsule())
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(
|
||||||
|
viewModel.selectedMedia?.id == media.id ? Color(hex: 0x0B79FF) : .white.opacity(0.75),
|
||||||
|
lineWidth: viewModel.selectedMedia?.id == media.id ? 3 : 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.moveMedia(1)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 22, weight: .heavy))
|
||||||
|
.foregroundStyle(Color(hex: 0x326EAE))
|
||||||
|
.frame(width: 28, height: 100)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.white.opacity(0.72), in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.stroke(.white.opacity(0.85), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前视频 / 照片模式没有素材时展示的空状态。
|
||||||
|
private var mediaEmptyPanel: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: viewModel.selectedMode == .video ? "play.rectangle.fill" : "photo.on.rectangle.angled")
|
||||||
|
.font(.system(size: 44, weight: .semibold))
|
||||||
|
.foregroundStyle(Color(hex: 0x57A2FF))
|
||||||
|
Text(viewModel.selectedMode == .video ? "暂无视频" : "暂无照片")
|
||||||
|
.font(.system(size: 19, weight: .bold))
|
||||||
|
.foregroundStyle(Color(hex: 0x1E5DA8))
|
||||||
|
Text("敬请期待更多精彩内容")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Color(hex: 0x6B83A8))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 172)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部主视觉区域:展示当前选中的视频或图片;图片不跳转二级页。
|
||||||
|
private var heroMedia: some View {
|
||||||
|
Group {
|
||||||
|
if let media = viewModel.selectedMedia {
|
||||||
|
Button {
|
||||||
|
if media.mode == .video {
|
||||||
|
showPlayToast = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
ScenicPromotionMediaArtwork(media: media, compact: false, useHeroAsset: true)
|
||||||
|
.frame(height: 220)
|
||||||
|
|
||||||
|
if media.heroAssetName == nil {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.clear, .black.opacity(0.56)],
|
||||||
|
startPoint: .center,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if media.mode == .video {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
.font(.system(size: 30, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
.background(.white.opacity(0.2), in: Circle())
|
||||||
|
.overlay(Circle().stroke(.white, lineWidth: 3))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Label(media.title, systemImage: "mappin.circle.fill")
|
||||||
|
.font(.system(size: 20, weight: .heavy))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Text(media.subtitle)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.92))
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.stroke(.white.opacity(0.86), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
mediaEmptyPanel
|
||||||
|
.frame(height: 220)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.white.opacity(0.7), in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.stroke(.white.opacity(0.85), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宣传页 Loading 骨架屏,尺寸跟正常模块保持一致。
|
||||||
|
private var loadingView: some View {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: 18) {
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.fill(.white.opacity(0.72))
|
||||||
|
.frame(height: 110)
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(.white.opacity(0.72))
|
||||||
|
.frame(height: 50)
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) {
|
||||||
|
ForEach(0..<3, id: \.self) { _ in
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(.white.opacity(0.72))
|
||||||
|
.frame(height: 92)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.fill(.white.opacity(0.72))
|
||||||
|
.frame(height: 96)
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.fill(.white.opacity(0.72))
|
||||||
|
.frame(height: 112)
|
||||||
|
RoundedRectangle(cornerRadius: 18)
|
||||||
|
.fill(.white.opacity(0.72))
|
||||||
|
.frame(height: 220)
|
||||||
|
}
|
||||||
|
.redacted(reason: .placeholder)
|
||||||
|
.padding(ScenicPromotionLayout.screenEdgePadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宣传页 Empty / Error 通用状态视图。
|
||||||
|
private func fullStateView(
|
||||||
|
icon: String,
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
buttonTitle: String,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
VStack(spacing: 18) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 48, weight: .semibold))
|
||||||
|
.foregroundStyle(Color(hex: 0x0B79FF))
|
||||||
|
.frame(width: 92, height: 92)
|
||||||
|
.background(.white.opacity(0.75), in: RoundedRectangle(cornerRadius: 24))
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.foregroundStyle(Color(hex: 0x143D78))
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(Color(hex: 0x5F789E))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: action) {
|
||||||
|
Text(buttonTitle)
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: 150, height: 46)
|
||||||
|
.background(Color(hex: 0x0B79FF), in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 宣传页素材图片渲染
|
||||||
|
|
||||||
|
private struct ScenicPromotionMediaArtwork: View {
|
||||||
|
let media: ScenicPromotionMedia
|
||||||
|
let compact: Bool
|
||||||
|
let useHeroAsset: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
if let assetName = useHeroAsset ? (media.heroAssetName ?? media.assetName) : media.assetName {
|
||||||
|
Image(assetName)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: media.palette.map { Color(hex: $0) },
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
ScenicPromotionMountainLayer()
|
||||||
|
.fill(.white.opacity(0.28))
|
||||||
|
.frame(height: proxy.size.height * 0.45)
|
||||||
|
.offset(y: proxy.size.height * 0.02)
|
||||||
|
|
||||||
|
ScenicPromotionMountainLayer()
|
||||||
|
.fill(Color(hex: 0x0F5B85, alpha: 0.22))
|
||||||
|
.frame(height: proxy.size.height * 0.38)
|
||||||
|
.offset(y: proxy.size.height * 0.13)
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(hex: 0x2D9D4E, alpha: 0.52))
|
||||||
|
.frame(height: proxy.size.height * 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !compact {
|
||||||
|
Image(systemName: "figure.walk")
|
||||||
|
.font(.system(size: 74, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.82))
|
||||||
|
.offset(x: proxy.size.width * 0.23, y: proxy.size.height * 0.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: compact ? 12 : 18))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有本地图片资源时使用的备用山形占位图。
|
||||||
|
private struct ScenicPromotionMountainLayer: Shape {
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.width * 0.16, y: rect.height * 0.42))
|
||||||
|
path.addLine(to: CGPoint(x: rect.width * 0.28, y: rect.height * 0.68))
|
||||||
|
path.addLine(to: CGPoint(x: rect.width * 0.42, y: rect.height * 0.25))
|
||||||
|
path.addLine(to: CGPoint(x: rect.width * 0.58, y: rect.height * 0.6))
|
||||||
|
path.addLine(to: CGPoint(x: rect.width * 0.76, y: rect.height * 0.18))
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.height * 0.56))
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||||
|
path.closeSubpath()
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宣传页全屏背景,叠加轻量蓝绿色遮罩以贴近参考图。
|
||||||
|
private struct ScenicPromotionSkyBackground: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
Image("ScenicPromotionBackground")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.overlay(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(hex: 0x0E88FF, alpha: 0.12),
|
||||||
|
Color(hex: 0xE9F8FF, alpha: 0.5),
|
||||||
|
Color(hex: 0x82D860, alpha: 0.16)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(hex: 0x1593FF, alpha: 0.16),
|
||||||
|
Color(hex: 0xBFE9FF, alpha: 0.10),
|
||||||
|
Color(hex: 0xEAF8FF, alpha: 0.42)
|
||||||
|
],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.clear, Color(hex: 0x62BA4C, alpha: 0.18)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.frame(height: 260)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶部二维码图片,快速点击三次进入设置页密码弹窗。
|
||||||
|
private struct ScenicPromotionQRCodeView: View {
|
||||||
|
var body: some View {
|
||||||
|
Image("ScenicPromotionQRCode")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,6 +48,7 @@ final class HomeMenuRouterTests: XCTestCase {
|
|||||||
("location_report", .destination(.locationReport)),
|
("location_report", .destination(.locationReport)),
|
||||||
("location_report_history", .destination(.locationReportHistory)),
|
("location_report_history", .destination(.locationReportHistory)),
|
||||||
("report_photographer", .destination(.photographerReport)),
|
("report_photographer", .destination(.photographerReport)),
|
||||||
|
("promotion_page", .destination(.scenicPromotion)),
|
||||||
("pm", .destination(.projectManagement)),
|
("pm", .destination(.projectManagement)),
|
||||||
("project_edit", .destination(.projectManagement)),
|
("project_edit", .destination(.projectManagement)),
|
||||||
("pm_manager", .destination(.pmProjectManagement)),
|
("pm_manager", .destination(.pmProjectManagement)),
|
||||||
|
|||||||
183
suixinkanTests/ScenicPromotion/ScenicPromotionTests.swift
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
//
|
||||||
|
// ScenicPromotionTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/7/2.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 宣传页主页面 ViewModel 测试,覆盖加载、场景切换与素材选中逻辑。
|
||||||
|
@MainActor
|
||||||
|
final class ScenicPromotionViewModelTests: XCTestCase {
|
||||||
|
/// 测试正常场景加载后进入 normal 状态并选中首个打卡点与素材。
|
||||||
|
func testApplyNormalScenarioLoadsBoardAndSelection() {
|
||||||
|
let viewModel = ScenicPromotionViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.pageState, .normal)
|
||||||
|
XCTAssertNotNil(viewModel.board)
|
||||||
|
XCTAssertEqual(viewModel.board?.scenicName, "那拉提景区")
|
||||||
|
XCTAssertNotNil(viewModel.selectedSpot)
|
||||||
|
XCTAssertNotNil(viewModel.selectedMedia)
|
||||||
|
XCTAssertFalse(viewModel.mediaItems.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试空数据场景清空看板并进入 empty 状态。
|
||||||
|
func testApplyEmptyScenarioClearsBoard() {
|
||||||
|
let viewModel = ScenicPromotionViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
viewModel.applyScenario(.empty)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.pageState, .empty)
|
||||||
|
XCTAssertNil(viewModel.board)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试异常场景进入 error 状态。
|
||||||
|
func testApplyErrorScenarioSetsErrorState() {
|
||||||
|
let viewModel = ScenicPromotionViewModel()
|
||||||
|
viewModel.applyScenario(.error)
|
||||||
|
|
||||||
|
if case .error = viewModel.pageState {
|
||||||
|
XCTAssertTrue(true)
|
||||||
|
} else {
|
||||||
|
XCTFail("Expected error page state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试无媒体场景清空素材列表。
|
||||||
|
func testNoMediaScenarioHidesMediaItems() {
|
||||||
|
let viewModel = ScenicPromotionViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
viewModel.applyScenario(.noMedia)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.mediaItems.isEmpty)
|
||||||
|
XCTAssertNil(viewModel.selectedMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试无排队场景隐藏排队信息。
|
||||||
|
func testNoQueueScenarioHidesQueueInfo() {
|
||||||
|
let viewModel = ScenicPromotionViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
viewModel.applyScenario(.noQueue)
|
||||||
|
|
||||||
|
XCTAssertNil(viewModel.queue)
|
||||||
|
XCTAssertFalse(viewModel.hasQueueData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试切换打卡点与素材模式会更新选中素材。
|
||||||
|
func testSelectSpotAndModeUpdatesSelectedMedia() {
|
||||||
|
let viewModel = ScenicPromotionViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
|
||||||
|
guard let board = viewModel.board, board.spots.count > 1 else {
|
||||||
|
XCTFail("Expected multiple spots in mock board")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let secondSpot = board.spots[1]
|
||||||
|
viewModel.selectSpot(secondSpot)
|
||||||
|
XCTAssertEqual(viewModel.selectedSpot?.id, secondSpot.id)
|
||||||
|
|
||||||
|
viewModel.selectMode(.photo)
|
||||||
|
XCTAssertEqual(viewModel.selectedMode, .photo)
|
||||||
|
XCTAssertEqual(viewModel.selectedMedia?.mode, .photo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试左右切换素材循环选中。
|
||||||
|
func testMoveMediaCyclesSelection() {
|
||||||
|
let viewModel = ScenicPromotionViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
|
||||||
|
guard viewModel.mediaItems.count > 1 else {
|
||||||
|
XCTFail("Expected multiple media items")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstID = viewModel.selectedMedia?.id
|
||||||
|
viewModel.moveMedia(1)
|
||||||
|
XCTAssertNotEqual(viewModel.selectedMedia?.id, firstID)
|
||||||
|
|
||||||
|
viewModel.moveMedia(-1)
|
||||||
|
XCTAssertEqual(viewModel.selectedMedia?.id, firstID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 宣传页设置 ViewModel 测试,覆盖上传校验与提交规则。
|
||||||
|
@MainActor
|
||||||
|
final class ScenicPromotionSettingsViewModelTests: XCTestCase {
|
||||||
|
/// 测试正常场景加载 Mock 配置数据。
|
||||||
|
func testApplyNormalScenarioLoadsSettingsData() {
|
||||||
|
let viewModel = ScenicPromotionSettingsViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.pageState, .normal)
|
||||||
|
XCTAssertFalse(viewModel.queuePoints.isEmpty)
|
||||||
|
XCTAssertFalse(viewModel.materialPoints.isEmpty)
|
||||||
|
XCTAssertFalse(viewModel.selectedQueuePointIDs.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试未选择素材点时上传会被拦截。
|
||||||
|
func testAddMaterialFailsWithoutMaterialPoint() {
|
||||||
|
let viewModel = ScenicPromotionSettingsViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
viewModel.selectedMaterialPointID = nil
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.addMaterial(kind: .video))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试选择素材点后可以 Mock 上传素材。
|
||||||
|
func testAddMaterialSucceedsWithMaterialPoint() {
|
||||||
|
let viewModel = ScenicPromotionSettingsViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
|
||||||
|
guard let point = viewModel.materialPoints.first else {
|
||||||
|
XCTFail("Expected material points")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.selectMaterialPoint(point)
|
||||||
|
let beforeCount = viewModel.materials(for: .video).count
|
||||||
|
XCTAssertTrue(viewModel.addMaterial(kind: .video))
|
||||||
|
XCTAssertEqual(viewModel.materials(for: .video).count, beforeCount + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试删除素材会从当前点位列表移除。
|
||||||
|
func testDeleteMaterialRemovesItem() {
|
||||||
|
let viewModel = ScenicPromotionSettingsViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
|
||||||
|
guard let point = viewModel.materialPoints.first else {
|
||||||
|
XCTFail("Expected material points")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.selectMaterialPoint(point)
|
||||||
|
guard viewModel.addMaterial(kind: .image),
|
||||||
|
let material = viewModel.materials(for: .image).last else {
|
||||||
|
XCTFail("Expected uploaded material")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.deleteMaterial(material)
|
||||||
|
XCTAssertFalse(viewModel.materials(for: .image).contains(where: { $0.id == material.id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试未绑定排队点时提交失败。
|
||||||
|
func testSubmitFailsWithoutQueuePoints() {
|
||||||
|
let viewModel = ScenicPromotionSettingsViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
viewModel.selectedQueuePointIDs = []
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.submit())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试绑定排队点后提交成功。
|
||||||
|
func testSubmitSucceedsWithQueuePoints() {
|
||||||
|
let viewModel = ScenicPromotionSettingsViewModel()
|
||||||
|
viewModel.applyScenario(.normal)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.submit())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -112,6 +112,7 @@
|
|||||||
| 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 |
|
| 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 |
|
||||||
| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 |
|
| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 |
|
||||||
| 完成 | 举报摄影师 | `photographer_report` 已接真实页面;支持提交举报、我的举报、详情、补充证据、附近风险地图;当前为 Mock 数据闭环。 | 后续接入真实 API 时保持页面字段和交互不变。 |
|
| 完成 | 举报摄影师 | `photographer_report` 已接真实页面;支持提交举报、我的举报、详情、补充证据、附近风险地图;当前为 Mock 数据闭环。 | 后续接入真实 API 时保持页面字段和交互不变。 |
|
||||||
|
| 完成 | 宣传页 | `scenic_promotion` 已接 Mock 演示页;支持打卡点切换、排队导览、视频/照片素材、隐藏设置入口。 | 后续接入真实 API 时替换 Mock 数据来源。 |
|
||||||
| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 |
|
| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 |
|
||||||
| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 |
|
| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 |
|
||||||
| 完成 | 合作订单 | `cooperation_order` 已接真实页面;双 Tab 列表(获客线索/获客订单)、合作获客员、扫码绑定、主 Tab 扫码识别 sale_user_bind。 | 与订单模块 orderType 11/14 订单来源联动。 |
|
| 完成 | 合作订单 | `cooperation_order` 已接真实页面;双 Tab 列表(获客线索/获客订单)、合作获客员、扫码绑定、主 Tab 扫码识别 sale_user_bind。 | 与订单模块 orderType 11/14 订单来源联动。 |
|
||||||
|
|||||||