新增宣传页 Mock 模块并接入首页路由。
从 xh_test 同步 ScenicPromotion 演示页,补齐 MVVM 结构、本地资源、单元测试与模块文档,注册 promotion_page 首页入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,598 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,789 @@
|
||||
//
|
||||
// ScenicPromotionViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/2.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - 宣传页布局常量
|
||||
|
||||
private enum ScenicPromotionLayout {
|
||||
// 主页面内容距离屏幕左右边缘各 20px,不代表模块之间的间距。
|
||||
static let screenEdgePadding: CGFloat = 20
|
||||
// 模块之间保持轻量垂直间距,避免误用 20px 做模块间隔。
|
||||
static let moduleVerticalSpacing: CGFloat = 12
|
||||
// 打卡点标签区最多一屏露出 5 个,更多时横向滚动。
|
||||
static let visibleSpotTabCount: CGFloat = 5
|
||||
}
|
||||
|
||||
// MARK: - 宣传页隐藏设置路由
|
||||
|
||||
private struct ScenicPromotionSettingsRoute: Identifiable, Hashable {
|
||||
let id = "promotion_page_settings"
|
||||
}
|
||||
|
||||
// MARK: - 宣传页主视图
|
||||
|
||||
struct ScenicPromotionView: View {
|
||||
// 页面主数据和交互状态都收敛到 ViewModel,View 只负责布局和事件转发。
|
||||
@StateObject private var viewModel = ScenicPromotionViewModel()
|
||||
// 二维码三连击后进入设置页,使用轻量本地路由控制跳转。
|
||||
@State private var settingsRoute: ScenicPromotionSettingsRoute?
|
||||
// 记录二维码短时间点击次数,用于触发隐藏密码入口。
|
||||
@State private var qrTapCount = 0
|
||||
@State private var qrTapResetTask: Task<Void, Never>?
|
||||
// 设置入口密码弹窗状态。
|
||||
@State private var showPasswordPrompt = false
|
||||
@State private var settingsPassword = ""
|
||||
@State private var showPasswordError = false
|
||||
// 长按二维码用于产品演示状态切换,不占用页面可见区域。
|
||||
@State private var showDemoStateMenu = false
|
||||
// 主视觉视频点击后展示模拟播放提示。
|
||||
@State private var showPlayToast = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ScenicPromotionSkyBackground()
|
||||
.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
switch viewModel.pageState {
|
||||
case .idle, .loading:
|
||||
loadingView
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||
case .empty:
|
||||
fullStateView(
|
||||
icon: "doc.text.image",
|
||||
title: "暂无宣传页数据",
|
||||
message: "当前景区还没有配置排队导览宣传内容",
|
||||
buttonTitle: "重新加载"
|
||||
) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
case .error(let message):
|
||||
fullStateView(
|
||||
icon: "wifi.exclamationmark",
|
||||
title: "加载失败",
|
||||
message: message,
|
||||
buttonTitle: "重试"
|
||||
) {
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
case .normal:
|
||||
contentView
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.28), value: viewModel.pageState)
|
||||
}
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
.appNavigationDestination(item: $settingsRoute) { _ in
|
||||
ScenicPromotionSettingsView()
|
||||
}
|
||||
.alert("输入管理密码", isPresented: $showPasswordPrompt) {
|
||||
SecureField("请输入密码", text: $settingsPassword)
|
||||
Button("取消", role: .cancel) {
|
||||
settingsPassword = ""
|
||||
}
|
||||
Button("进入") {
|
||||
verifySettingsPassword()
|
||||
}
|
||||
} message: {
|
||||
Text("连续点击二维码 3 次后可进入宣传页设置")
|
||||
}
|
||||
.alert("密码错误", isPresented: $showPasswordError) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text("请输入正确密码")
|
||||
}
|
||||
.alert("视频播放", isPresented: $showPlayToast) {
|
||||
Button("知道了", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Demo 已模拟播放入口,后续接入真实视频地址即可播放。")
|
||||
}
|
||||
.confirmationDialog("演示状态", isPresented: $showDemoStateMenu, titleVisibility: .visible) {
|
||||
ForEach(ScenicPromotionDemoScenario.allCases) { scenario in
|
||||
Button(scenario.title) {
|
||||
viewModel.applyScenario(scenario)
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.onDisappear {
|
||||
qrTapResetTask?.cancel()
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// 宣传页正常内容,从上到下还原参考图的信息层级。
|
||||
private var contentView: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: ScenicPromotionLayout.moduleVerticalSpacing) {
|
||||
if let board = viewModel.board {
|
||||
header(board)
|
||||
spotTabs(board.spots)
|
||||
metricsGrid
|
||||
queuePanel
|
||||
mediaToolbar
|
||||
mediaStrip
|
||||
heroMedia
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, ScenicPromotionLayout.screenEdgePadding)
|
||||
.padding(.top, 28)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部标题、说明文案和二维码入口。
|
||||
private func header(_ board: ScenicPromotionBoard) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
VStack(spacing: 7) {
|
||||
Text("\(board.scenicName) · 排队导览")
|
||||
.font(.system(size: 26, weight: .heavy))
|
||||
.foregroundStyle(Color(hex: 0x0E3F91))
|
||||
.multilineTextAlignment(.center)
|
||||
.minimumScaleFactor(0.72)
|
||||
.lineLimit(1)
|
||||
.shadow(color: .white.opacity(0.55), radius: 0, x: 0, y: 2)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Rectangle()
|
||||
.fill(Color(hex: 0x2F80ED, alpha: 0.35))
|
||||
.frame(width: 36, height: 1)
|
||||
Text(board.subtitle)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x1D5DA8))
|
||||
Rectangle()
|
||||
.fill(Color(hex: 0x2F80ED, alpha: 0.35))
|
||||
.frame(width: 36, height: 1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal, 62)
|
||||
|
||||
Button {
|
||||
handleQRCodeTap()
|
||||
} label: {
|
||||
ScenicPromotionQRCodeView()
|
||||
.frame(width: 56, height: 56)
|
||||
.padding(5)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 9))
|
||||
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.16), radius: 10, y: 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
LongPressGesture(minimumDuration: 0.7)
|
||||
.onEnded { _ in
|
||||
showDemoStateMenu = true
|
||||
}
|
||||
)
|
||||
.accessibilityLabel("二维码设置入口")
|
||||
}
|
||||
.frame(height: 96)
|
||||
}
|
||||
|
||||
// 二维码快速点击 3 次后弹出设置密码输入框。
|
||||
private func handleQRCodeTap() {
|
||||
qrTapCount += 1
|
||||
qrTapResetTask?.cancel()
|
||||
|
||||
if qrTapCount >= 3 {
|
||||
qrTapCount = 0
|
||||
settingsPassword = ""
|
||||
showPasswordPrompt = true
|
||||
return
|
||||
}
|
||||
|
||||
qrTapResetTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 1_100_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
qrTapCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前 Mock 密码为 1,通过后进入宣传页设置页。
|
||||
private func verifySettingsPassword() {
|
||||
if settingsPassword == "1" {
|
||||
settingsPassword = ""
|
||||
settingsRoute = ScenicPromotionSettingsRoute()
|
||||
} else {
|
||||
settingsPassword = ""
|
||||
showPasswordError = true
|
||||
}
|
||||
}
|
||||
|
||||
// 打卡点标签:接口返回少于 5 个时等分,超过 5 个时每屏露出 5 个并支持横向滚动。
|
||||
@ViewBuilder
|
||||
private func spotTabs(_ spots: [ScenicPromotionSpot]) -> some View {
|
||||
if spots.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
GeometryReader { proxy in
|
||||
let visibleCount = min(CGFloat(max(spots.count, 1)), ScenicPromotionLayout.visibleSpotTabCount)
|
||||
let tabWidth = proxy.size.width / visibleCount
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(spots) { spot in
|
||||
spotTabButton(spot)
|
||||
.frame(width: tabWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDisabled(spots.count <= Int(ScenicPromotionLayout.visibleSpotTabCount))
|
||||
}
|
||||
.frame(height: 42)
|
||||
.background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(.white.opacity(0.8), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.12), radius: 12, y: 5)
|
||||
}
|
||||
}
|
||||
|
||||
// 单个打卡点标签按钮,字号和图标压小后保证 5 个标签可以露出。
|
||||
private func spotTabButton(_ spot: ScenicPromotionSpot) -> some View {
|
||||
let isSelected = viewModel.selectedSpot?.id == spot.id
|
||||
return Button {
|
||||
viewModel.selectSpot(spot)
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: spot.iconName)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
Text(spot.name)
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.66)
|
||||
}
|
||||
.foregroundStyle(isSelected ? .white : Color(hex: 0x14213D))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 42)
|
||||
.padding(.horizontal, 4)
|
||||
.background(
|
||||
Group {
|
||||
if isSelected {
|
||||
LinearGradient(
|
||||
colors: [Color(hex: 0x1286FF), Color(hex: 0x006BFF)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// 三个排队数据卡片:当前排队人数、预计等待时间、平均等待时长。
|
||||
private var metricsGrid: some View {
|
||||
let queue = viewModel.queue
|
||||
let metrics: [(String, String, String, Color)] = [
|
||||
("当前排队人数", queue.map { "\($0.waitingPeople)" } ?? "--", "人", Color(hex: 0x1580FF)),
|
||||
("预计等待时间", queue.map { "\($0.estimatedMinutes)" } ?? "--", "分钟", Color(hex: 0xF28C18)),
|
||||
("平均等待时长", queue.map { "\($0.averageMinutes)" } ?? "--", "分钟", Color(hex: 0x2D8E43))
|
||||
]
|
||||
|
||||
return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) {
|
||||
ForEach(metrics, id: \.0) { metric in
|
||||
VStack(spacing: 8) {
|
||||
Text(metric.0)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x113B79))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.76)
|
||||
HStack(alignment: .lastTextBaseline, spacing: 2) {
|
||||
Text(metric.1)
|
||||
.font(.system(size: 34, weight: .heavy))
|
||||
.foregroundStyle(metric.3)
|
||||
.minimumScaleFactor(0.55)
|
||||
if metric.1 != "--" {
|
||||
Text(metric.2)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(metric.3)
|
||||
}
|
||||
}
|
||||
if metric.1 == "--" {
|
||||
Text("暂无数据")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Color(hex: 0x5272A5))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 92)
|
||||
.background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(.white.opacity(0.85), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前叫号模块:有排队数据时展示叫号,无排队时展示空状态。
|
||||
private var queuePanel: some View {
|
||||
Group {
|
||||
if let queue = viewModel.queue {
|
||||
HStack(spacing: 10) {
|
||||
queueSide(icon: "camera.aperture", title: "下一位", value: queue.nextNumber, tint: Color(hex: 0x197BFF))
|
||||
Divider()
|
||||
.frame(height: 52)
|
||||
VStack(spacing: 0) {
|
||||
Text("当前叫号")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0x123F86))
|
||||
Text(queue.currentNumber)
|
||||
.font(.system(size: 38, weight: .heavy))
|
||||
.foregroundStyle(Color(hex: 0x197BFF))
|
||||
.minimumScaleFactor(0.55)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
Divider()
|
||||
.frame(height: 52)
|
||||
queueSide(icon: "person.2.fill", title: "拍摄用时", value: queue.shootingDuration, tint: Color(hex: 0x7A4DFF))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
} else {
|
||||
HStack(spacing: 18) {
|
||||
Image(systemName: "list.clipboard")
|
||||
.font(.system(size: 46, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x2F80ED))
|
||||
.frame(width: 92, height: 92)
|
||||
.background(Color(hex: 0xEAF4FF), in: RoundedRectangle(cornerRadius: 18))
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("当前暂无排队信息")
|
||||
.font(.system(size: 22, weight: .heavy))
|
||||
.foregroundStyle(Color(hex: 0x123F86))
|
||||
Text("可先浏览景点样片,稍后再查看排队动态")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Color(hex: 0x5272A5))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.white.opacity(0.86), in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(.white.opacity(0.9), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.10), radius: 14, y: 6)
|
||||
}
|
||||
|
||||
// 叫号模块左右两侧的小信息块,分别用于“下一位”和“拍摄用时”。
|
||||
private func queueSide(icon: String, title: String, value: String, tint: Color) -> some View {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(tint.opacity(0.13), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color(hex: 0x113B79))
|
||||
Text(value)
|
||||
.font(.system(size: 13, weight: .heavy))
|
||||
.foregroundStyle(tint)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.minimumScaleFactor(0.76)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// 视频 / 照片切换工具条,点击后刷新横向素材和底部主视觉。
|
||||
private var mediaToolbar: some View {
|
||||
HStack(spacing: 14) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(ScenicPromotionMediaMode.allCases) { mode in
|
||||
Button {
|
||||
viewModel.selectMode(mode)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: mode.iconName)
|
||||
Text(mode.title)
|
||||
}
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(viewModel.selectedMode == mode ? .white : Color(hex: 0x1B4D91))
|
||||
.frame(width: 68, height: 34)
|
||||
.background(
|
||||
viewModel.selectedMode == mode
|
||||
? Color(hex: 0x0B79FF)
|
||||
: Color.clear
|
||||
)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(.white.opacity(0.78), in: Capsule())
|
||||
.overlay(Capsule().stroke(.white.opacity(0.9), lineWidth: 1))
|
||||
|
||||
Spacer()
|
||||
|
||||
Label("触屏切换视频/照片,左右滑动查看更多", systemImage: "hand.tap.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x124E97))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.58)
|
||||
}
|
||||
}
|
||||
|
||||
// 红框素材选择器:支持横向滚动、左右切换,点击缩略图只联动底部主视觉。
|
||||
private var mediaStrip: some View {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
viewModel.moveMedia(-1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 22, weight: .heavy))
|
||||
.foregroundStyle(Color(hex: 0x326EAE))
|
||||
.frame(width: 28, height: 100)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if viewModel.mediaItems.isEmpty {
|
||||
mediaEmptyPanel
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(viewModel.mediaItems) { media in
|
||||
Button {
|
||||
viewModel.selectMedia(media)
|
||||
} label: {
|
||||
ScenicPromotionMediaArtwork(media: media, compact: true, useHeroAsset: false)
|
||||
.frame(width: media.previewWidth, height: 66)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if let duration = media.duration {
|
||||
Text(duration)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.black.opacity(0.42), in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(
|
||||
viewModel.selectedMedia?.id == media.id ? Color(hex: 0x0B79FF) : .white.opacity(0.75),
|
||||
lineWidth: viewModel.selectedMedia?.id == media.id ? 3 : 1
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.moveMedia(1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 22, weight: .heavy))
|
||||
.foregroundStyle(Color(hex: 0x326EAE))
|
||||
.frame(width: 28, height: 100)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.white.opacity(0.72), in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(.white.opacity(0.85), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
// 当前视频 / 照片模式没有素材时展示的空状态。
|
||||
private var mediaEmptyPanel: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: viewModel.selectedMode == .video ? "play.rectangle.fill" : "photo.on.rectangle.angled")
|
||||
.font(.system(size: 44, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x57A2FF))
|
||||
Text(viewModel.selectedMode == .video ? "暂无视频" : "暂无照片")
|
||||
.font(.system(size: 19, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0x1E5DA8))
|
||||
Text("敬请期待更多精彩内容")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Color(hex: 0x6B83A8))
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 172)
|
||||
}
|
||||
|
||||
// 底部主视觉区域:展示当前选中的视频或图片;图片不跳转二级页。
|
||||
private var heroMedia: some View {
|
||||
Group {
|
||||
if let media = viewModel.selectedMedia {
|
||||
Button {
|
||||
if media.mode == .video {
|
||||
showPlayToast = true
|
||||
}
|
||||
} label: {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
ScenicPromotionMediaArtwork(media: media, compact: false, useHeroAsset: true)
|
||||
.frame(height: 220)
|
||||
|
||||
if media.heroAssetName == nil {
|
||||
LinearGradient(
|
||||
colors: [.clear, .black.opacity(0.56)],
|
||||
startPoint: .center,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if media.mode == .video {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 64, height: 64)
|
||||
.background(.white.opacity(0.2), in: Circle())
|
||||
.overlay(Circle().stroke(.white, lineWidth: 3))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
Label(media.title, systemImage: "mappin.circle.fill")
|
||||
.font(.system(size: 20, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
Text(media.subtitle)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(.white.opacity(0.86), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
mediaEmptyPanel
|
||||
.frame(height: 220)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.white.opacity(0.7), in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(.white.opacity(0.85), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 宣传页 Loading 骨架屏,尺寸跟正常模块保持一致。
|
||||
private var loadingView: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 18) {
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(.white.opacity(0.72))
|
||||
.frame(height: 110)
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.white.opacity(0.72))
|
||||
.frame(height: 50)
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(.white.opacity(0.72))
|
||||
.frame(height: 92)
|
||||
}
|
||||
}
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(.white.opacity(0.72))
|
||||
.frame(height: 96)
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(.white.opacity(0.72))
|
||||
.frame(height: 112)
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(.white.opacity(0.72))
|
||||
.frame(height: 220)
|
||||
}
|
||||
.redacted(reason: .placeholder)
|
||||
.padding(ScenicPromotionLayout.screenEdgePadding)
|
||||
}
|
||||
}
|
||||
|
||||
// 宣传页 Empty / Error 通用状态视图。
|
||||
private func fullStateView(
|
||||
icon: String,
|
||||
title: String,
|
||||
message: String,
|
||||
buttonTitle: String,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
VStack(spacing: 18) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 48, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x0B79FF))
|
||||
.frame(width: 92, height: 92)
|
||||
.background(.white.opacity(0.75), in: RoundedRectangle(cornerRadius: 24))
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0x143D78))
|
||||
Text(message)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Color(hex: 0x5F789E))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: action) {
|
||||
Text(buttonTitle)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 150, height: 46)
|
||||
.background(Color(hex: 0x0B79FF), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(28)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 宣传页素材图片渲染
|
||||
|
||||
private struct ScenicPromotionMediaArtwork: View {
|
||||
let media: ScenicPromotionMedia
|
||||
let compact: Bool
|
||||
let useHeroAsset: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
if let assetName = useHeroAsset ? (media.heroAssetName ?? media.assetName) : media.assetName {
|
||||
Image(assetName)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
.clipped()
|
||||
} else {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: media.palette.map { Color(hex: $0) },
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
ScenicPromotionMountainLayer()
|
||||
.fill(.white.opacity(0.28))
|
||||
.frame(height: proxy.size.height * 0.45)
|
||||
.offset(y: proxy.size.height * 0.02)
|
||||
|
||||
ScenicPromotionMountainLayer()
|
||||
.fill(Color(hex: 0x0F5B85, alpha: 0.22))
|
||||
.frame(height: proxy.size.height * 0.38)
|
||||
.offset(y: proxy.size.height * 0.13)
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
Rectangle()
|
||||
.fill(Color(hex: 0x2D9D4E, alpha: 0.52))
|
||||
.frame(height: proxy.size.height * 0.2)
|
||||
}
|
||||
|
||||
if !compact {
|
||||
Image(systemName: "figure.walk")
|
||||
.font(.system(size: 74, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.offset(x: proxy.size.width * 0.23, y: proxy.size.height * 0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: compact ? 12 : 18))
|
||||
}
|
||||
}
|
||||
|
||||
// 没有本地图片资源时使用的备用山形占位图。
|
||||
private struct ScenicPromotionMountainLayer: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.width * 0.16, y: rect.height * 0.42))
|
||||
path.addLine(to: CGPoint(x: rect.width * 0.28, y: rect.height * 0.68))
|
||||
path.addLine(to: CGPoint(x: rect.width * 0.42, y: rect.height * 0.25))
|
||||
path.addLine(to: CGPoint(x: rect.width * 0.58, y: rect.height * 0.6))
|
||||
path.addLine(to: CGPoint(x: rect.width * 0.76, y: rect.height * 0.18))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.height * 0.56))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// 宣传页全屏背景,叠加轻量蓝绿色遮罩以贴近参考图。
|
||||
private struct ScenicPromotionSkyBackground: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Image("ScenicPromotionBackground")
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.overlay(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(hex: 0x0E88FF, alpha: 0.12),
|
||||
Color(hex: 0xE9F8FF, alpha: 0.5),
|
||||
Color(hex: 0x82D860, alpha: 0.16)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(hex: 0x1593FF, alpha: 0.16),
|
||||
Color(hex: 0xBFE9FF, alpha: 0.10),
|
||||
Color(hex: 0xEAF8FF, alpha: 0.42)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
LinearGradient(
|
||||
colors: [Color.clear, Color(hex: 0x62BA4C, alpha: 0.18)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部二维码图片,快速点击三次进入设置页密码弹窗。
|
||||
private struct ScenicPromotionQRCodeView: View {
|
||||
var body: some View {
|
||||
Image("ScenicPromotionQRCode")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user