Files
suixinkan_ios_new/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsViews.swift
汉秋 1b1e93b9ca 新增宣传页 Mock 模块并接入首页路由。
从 xh_test 同步 ScenicPromotion 演示页,补齐 MVVM 结构、本地资源、单元测试与模块文档,注册 promotion_page 首页入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 15:29:25 +08:00

599 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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