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

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

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

View File

@ -0,0 +1,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
}
}