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

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

790 lines
32 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.

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