// // ScenicPromotionViewModels.swift // suixinkan // // Created by Codex on 2026/7/2. // import Combine import Foundation import SwiftUI /// 宣传页主视图 ViewModel,驱动看板数据、打卡点切换与素材选中状态。 @MainActor final class ScenicPromotionViewModel: ObservableObject { @Published private(set) var pageState: ScenicPromotionPageState = .idle @Published private(set) var board: ScenicPromotionBoard? @Published var selectedSpotID: String? @Published var selectedMode: ScenicPromotionMediaMode = .video @Published var selectedMediaID: ScenicPromotionMedia.ID? @Published private(set) var activeScenario: ScenicPromotionDemoScenario = .normal /// 当前选中的打卡点;如果 selectedSpotID 为空,默认取第一个点位。 var selectedSpot: ScenicPromotionSpot? { guard let board else { return nil } return board.spots.first { $0.id == selectedSpotID } ?? board.spots.first } /// 无排队演示状态下返回 nil,让页面展示“当前暂无排队信息”。 var queue: ScenicPromotionQueueInfo? { guard activeScenario != .noQueue else { return nil } return selectedSpot?.queue } /// 当前素材列表随“视频 / 照片”切换变化,缩略图和主视觉共用这组数据。 var mediaItems: [ScenicPromotionMedia] { guard activeScenario != .noMedia else { return [] } return Self.mediaItems(for: selectedSpot, mode: selectedMode) } /// 当前主视觉素材;如果选中 ID 丢失,兜底展示列表第一条。 var selectedMedia: ScenicPromotionMedia? { let items = mediaItems return items.first { $0.id == selectedMediaID } ?? items.first } var hasQueueData: Bool { queue != nil } /// 首次进入页面时加载 Mock 数据。 func loadIfNeeded() async { guard pageState == .idle else { return } await load() } /// 模拟接口加载耗时,便于展示 Loading 状态。 func load() async { withAnimation(.easeInOut(duration: 0.25)) { pageState = .loading } try? await Task.sleep(nanoseconds: 350_000_000) guard !Task.isCancelled else { return } applyScenario(.normal) } /// 切换产品演示状态,覆盖正常、无媒体、无排队、加载、异常和空数据。 func applyScenario(_ scenario: ScenicPromotionDemoScenario) { activeScenario = scenario withAnimation(.easeInOut(duration: 0.28)) { switch scenario { case .normal, .noMedia, .noQueue: board = ScenicPromotionMockStore.mockBoard selectedSpotID = selectedSpotID ?? board?.spots.first?.id selectedMediaID = Self.mediaItems(for: selectedSpot, mode: selectedMode).first?.id pageState = .normal case .loading: pageState = .loading case .error: board = nil pageState = .error("宣传页数据加载失败,请稍后重试") case .empty: board = nil pageState = .empty } } } /// 切换打卡点时,默认选中当前素材类型下的第一条素材。 func selectSpot(_ spot: ScenicPromotionSpot) { withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { selectedSpotID = spot.id selectedMediaID = Self.mediaItems(for: spot, mode: selectedMode).first?.id } } /// 切换视频 / 照片时,底部主视觉同步切到该模式下第一条素材。 func selectMode(_ mode: ScenicPromotionMediaMode) { withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { selectedMode = mode selectedMediaID = Self.mediaItems(for: selectedSpot, mode: mode).first?.id } } /// 点击横向素材缩略图,只更新选中态并联动底部主视觉。 func selectMedia(_ media: ScenicPromotionMedia) { withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { selectedMediaID = media.id } } /// 左右箭头循环切换当前素材。 func moveMedia(_ offset: Int) { let items = mediaItems guard !items.isEmpty else { return } let currentIndex = items.firstIndex { $0.id == selectedMedia?.id } ?? 0 let nextIndex = (currentIndex + offset + items.count) % items.count selectMedia(items[nextIndex]) } /// 根据点位和素材类型取素材列表,避免切换模式时读到旧模式数据。 static func mediaItems( for spot: ScenicPromotionSpot?, mode: ScenicPromotionMediaMode ) -> [ScenicPromotionMedia] { switch mode { case .video: return spot?.videos ?? [] case .photo: return spot?.photos ?? [] } } } /// 宣传页设置 ViewModel,管理排队绑定、素材点、上传、删除和提交。 @MainActor final class ScenicPromotionSettingsViewModel: ObservableObject { @Published private(set) var pageState: ScenicPromotionSettingsPageState = .idle @Published private(set) var queuePoints: [ScenicPromotionQueuePoint] = [] @Published private(set) var materialPoints: [ScenicPromotionMaterialPoint] = [] @Published var selectedQueuePointIDs: Set = [] @Published var selectedMaterialPointID: String? @Published private(set) var materialsByPointID: [String: [ScenicPromotionManagedMaterial]] = [:] /// 当前要预览的已上传素材。 @Published var previewMaterial: ScenicPromotionManagedMaterial? /// 多选排队打卡点的展示文案。 var selectedQueuePointNames: String { let names = queuePoints .filter { selectedQueuePointIDs.contains($0.id) } .map(\.name) return names.isEmpty ? "请选择排队打卡点" : names.joined(separator: "、") } /// 当前素材上传所属打卡点。 var selectedMaterialPoint: ScenicPromotionMaterialPoint? { guard let selectedMaterialPointID else { return nil } return materialPoints.first { $0.id == selectedMaterialPointID } } /// 素材点选择框的展示文案。 var selectedMaterialPointName: String { selectedMaterialPoint?.name ?? "请选择素材所属打卡点" } /// 首次进入设置页时加载 Mock 配置。 func loadIfNeeded() async { guard pageState == .idle else { return } await load() } /// 模拟设置页加载过程。 func load() async { withAnimation(.easeInOut(duration: 0.24)) { pageState = .loading } try? await Task.sleep(nanoseconds: 260_000_000) guard !Task.isCancelled else { return } applyScenario(.normal) } /// 切换设置页演示状态。 func applyScenario(_ scenario: ScenicPromotionSettingsDemoScenario) { withAnimation(.easeInOut(duration: 0.24)) { switch scenario { case .normal: queuePoints = ScenicPromotionMockStore.mockQueuePoints materialPoints = ScenicPromotionMockStore.mockMaterialPoints selectedQueuePointIDs = ["queue_bridge", "queue_hendi"] selectedMaterialPointID = selectedMaterialPointID ?? ScenicPromotionMockStore.mockMaterialPoints.first?.id materialsByPointID = ScenicPromotionMockStore.mockMaterialsByPointID pageState = .normal case .loading: pageState = .loading case .empty: queuePoints = [] materialPoints = [] selectedQueuePointIDs = [] selectedMaterialPointID = nil materialsByPointID = [:] pageState = .empty case .error: queuePoints = [] materialPoints = [] selectedQueuePointIDs = [] selectedMaterialPointID = nil materialsByPointID = [:] pageState = .error("宣传页设置数据加载失败,请稍后重试") } } } /// 排队打卡点多选逻辑。 func toggleQueuePoint(_ point: ScenicPromotionQueuePoint) { withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) { if selectedQueuePointIDs.contains(point.id) { selectedQueuePointIDs.remove(point.id) } else { selectedQueuePointIDs.insert(point.id) } } } /// 切换素材所属打卡点后,页面会展示该点位已有素材。 func selectMaterialPoint(_ point: ScenicPromotionMaterialPoint) { withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) { selectedMaterialPointID = point.id } } /// 获取当前素材点下指定类型的素材。 func materials(for kind: ScenicPromotionUploadKind) -> [ScenicPromotionManagedMaterial] { guard let selectedMaterialPointID else { return [] } return (materialsByPointID[selectedMaterialPointID] ?? []) .filter { $0.kind == kind } } /// 模拟上传素材;未选择素材点时返回 false,由页面弹提示。 func addMaterial(kind: ScenicPromotionUploadKind) -> Bool { guard let point = selectedMaterialPoint else { return false } let currentItems = materialsByPointID[point.id] ?? [] let nextIndex = currentItems.filter { $0.kind == kind }.count + 1 let assetName = ScenicPromotionMockStore.assetName(for: kind, index: nextIndex) let material = ScenicPromotionManagedMaterial( id: "\(point.id)_\(kind.rawValue)_\(UUID().uuidString)", pointID: point.id, kind: kind, title: "\(point.name)\(kind.shortTitle)\(nextIndex)", subtitle: kind == .video ? "模拟上传视频,待后续接入真实素材" : "模拟上传图片,待后续接入真实素材", assetName: assetName, duration: kind == .video ? "00:\(String(format: "%02d", 20 + nextIndex * 3))" : nil, createdAt: "2026-06-25 19:\(String(format: "%02d", 20 + nextIndex))" ) withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { materialsByPointID[point.id, default: []].append(material) } return true } /// 删除当前素材点下的某个素材。 func deleteMaterial(_ material: ScenicPromotionManagedMaterial) { withAnimation(.spring(response: 0.25, dampingFraction: 0.86)) { materialsByPointID[material.pointID]?.removeAll { $0.id == material.id } } } /// 打开素材预览页。 func openMaterialPreview(_ material: ScenicPromotionManagedMaterial) { previewMaterial = material } /// 提交前校验至少绑定一个排队打卡点。 func submit() -> Bool { guard !selectedQueuePointIDs.isEmpty else { return false } return true } }