新增宣传页 Mock 模块并接入首页路由。

从 xh_test 同步 ScenicPromotion 演示页,补齐 MVVM 结构、本地资源、单元测试与模块文档,注册 promotion_page 首页入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 15:29:25 +08:00
parent a528d9b0d7
commit 1b1e93b9ca
30 changed files with 2661 additions and 4 deletions

View File

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