// // 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? // 设置入口密码弹窗状态。 @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() } }