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