// // 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( 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) } } }