从 xh_test 同步 ScenicPromotion 演示页,补齐 MVVM 结构、本地资源、单元测试与模块文档,注册 promotion_page 首页入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
271 lines
11 KiB
Swift
271 lines
11 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|