From 328c4321f4af7f1cdf9f211cea30d115bb7a80c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Thu, 2 Jul 2026 17:43:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=AE=A3=E4=BC=A0=E9=A1=B5?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E5=B9=B6=E6=89=A9=E5=85=85=20Mock=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=EF=BC=8C=E5=90=88=E4=BD=9C=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=8E=B7=E5=AE=A2=E5=91=98=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E5=90=8D=E5=B9=B6=E4=BF=AE=E5=A4=8D=E7=BA=BF=E7=B4=A2=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=BD=91=E6=A0=BC=E5=B0=BA=E5=AF=B8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../API/CooperationOrderAPI.swift | 17 + .../CooperationOrder/CooperationOrder.md | 9 +- .../Models/CooperationOrderModels.swift | 87 +- .../CooperationAcquirerViewModel.swift | 15 + .../Views/CooperationAcquirerView.swift | 45 + .../Views/CooperationOrderComponents.swift | 94 ++- .../Features/Orders/Models/OrderModels.swift | 8 + .../Orders/Models/OrderSourceModels.swift | 17 +- .../ScenicPromotion/ScenicPromotion.md | 9 +- .../Services/ScenicPromotionMockStore.swift | 183 ++++ .../ScenicPromotionViewModels.swift | 7 + .../ScenicPromotionBackgroundViews.swift | 66 ++ .../Views/ScenicPromotionContentView.swift | 71 ++ .../Views/ScenicPromotionFullStateView.swift | 65 ++ .../Views/ScenicPromotionHeaderView.swift | 71 ++ .../Views/ScenicPromotionHeroMediaView.swift | 96 +++ .../Views/ScenicPromotionLayout.swift | 24 + .../Views/ScenicPromotionLoadingView.swift | 52 ++ .../ScenicPromotionMaterialPreviewView.swift | 89 ++ .../ScenicPromotionMediaArtworkView.swift | 100 +++ .../Views/ScenicPromotionMediaEmptyView.swift | 44 + .../Views/ScenicPromotionMediaStripView.swift | 98 +++ .../ScenicPromotionMediaToolbarView.swift | 59 ++ .../ScenicPromotionMetricsGridView.swift | 69 ++ .../Views/ScenicPromotionPreviewSupport.swift | 48 ++ .../Views/ScenicPromotionQueuePanelView.swift | 118 +++ .../ScenicPromotionSettingsContentView.swift | 47 ++ .../ScenicPromotionSettingsHeaderView.swift | 51 ++ ...icPromotionSettingsMaterialPointCard.swift | 52 ++ ...enicPromotionSettingsMaterialRowView.swift | 69 ++ ...cPromotionSettingsMaterialUploadCard.swift | 87 ++ ...nicPromotionSettingsQueueBindingCard.swift | 59 ++ .../ScenicPromotionSettingsSharedViews.swift | 234 ++++++ ...ScenicPromotionSettingsSubmitBarView.swift | 38 + .../Views/ScenicPromotionSettingsView.swift | 113 +++ .../Views/ScenicPromotionSettingsViews.swift | 598 ------------- .../Views/ScenicPromotionSpotTabsView.swift | 101 +++ .../Views/ScenicPromotionView.swift | 145 ++++ .../Views/ScenicPromotionViews.swift | 789 ------------------ suixinkanTests/CooperationOrderAPITests.swift | 20 +- .../Fixtures/cooperative_salers_success.json | 1 + 41 files changed, 2529 insertions(+), 1436 deletions(-) create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionBackgroundViews.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionContentView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionFullStateView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeaderView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeroMediaView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLayout.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLoadingView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMaterialPreviewView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaArtworkView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaEmptyView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaStripView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaToolbarView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMetricsGridView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionPreviewSupport.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionQueuePanelView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsContentView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsHeaderView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialPointCard.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialRowView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialUploadCard.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsQueueBindingCard.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSharedViews.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSubmitBarView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsView.swift delete mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsViews.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSpotTabsView.swift create mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionView.swift delete mode 100644 suixinkan/Features/ScenicPromotion/Views/ScenicPromotionViews.swift diff --git a/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift b/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift index f422cee..03de8c9 100644 --- a/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift +++ b/suixinkan/Features/CooperationOrder/API/CooperationOrderAPI.swift @@ -33,6 +33,9 @@ protocol CooperationOrderServing { /// 更新带客单状态(如退款时 status=3)。 func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws + + /// 更新合作获客员备注名。 + func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws } @MainActor @@ -156,4 +159,18 @@ final class CooperationOrderAPI: CooperationOrderServing { ) ) } + + /// 更新合作获客员备注名。 + func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/sale-user/update-remark", + body: SaleUserUpdateRemarkRequest( + saleUserId: saleUserId, + photogRemarkName: photogRemarkName + ) + ) + ) + } } diff --git a/suixinkan/Features/CooperationOrder/CooperationOrder.md b/suixinkan/Features/CooperationOrder/CooperationOrder.md index 03a05c6..d79f581 100644 --- a/suixinkan/Features/CooperationOrder/CooperationOrder.md +++ b/suixinkan/Features/CooperationOrder/CooperationOrder.md @@ -31,6 +31,13 @@ 2. 扫码或主 Tab 扫码识别 `{"method":"sale_user_bind","sale_user_id":N}` 3. `BindAcquirerView` 加载邀请信息 → 确认 → `POST .../sale-user/accept-bind` +### 获客员备注名 + +1. 合作获客员列表每行右上角「修改备注」→ 弹窗编辑备注名 +2. 保存 → `POST .../sale-user/update-remark`,Body:`{ "sale_user_id": Int, "photog_remark_name": String }` +3. 展示规则:优先 `photog_remark_name`,为空时回退获客员原名(`name` / `saler_name` / `sale_user_name`) +4. 绑定确认页(`BindAcquirerView`)仍展示真实获客员名,不使用备注名 + ## 与订单模块关系 订单管理(`orderType` 11 摄影师跟拍 / 14 线下扫码)在订单卡片展示「订单来源」: @@ -43,7 +50,7 @@ | 文件 | 说明 | | --- | --- | -| `API/CooperationOrderAPI.swift` | 8 个 sale-user / cooperative 接口 | +| `API/CooperationOrderAPI.swift` | 9 个 sale-user / cooperative 接口 | | `Models/CooperationOrderModels.swift` | 实体与解码 | | `Services/CooperationOrderFeature.swift` | 权限 URI 常量 | | `ViewModels/*` | 列表、获客员、绑定 ViewModel | diff --git a/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift b/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift index ed94807..02d2770 100644 --- a/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift +++ b/suixinkan/Features/CooperationOrder/Models/CooperationOrderModels.swift @@ -13,6 +13,11 @@ extension String { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? fallback : trimmed } + + /// 获客员展示名:优先备注名,其次主名称,最后回退名称。 + func saleUserDisplayName(primaryName: String, fallbackName: String = "") -> String { + cooperationNonEmptyOrDefault(primaryName.cooperationNonEmptyOrDefault(fallbackName)) + } } /// 带客单状态更新请求体。 @@ -20,6 +25,17 @@ struct ReferralOrderStatusUpdateRequest: Encodable { let status: Int } +/// 合作获客员备注名更新请求体。 +struct SaleUserUpdateRemarkRequest: Encodable { + let saleUserId: Int + let photogRemarkName: String + + enum CodingKeys: String, CodingKey { + case saleUserId = "sale_user_id" + case photogRemarkName = "photog_remark_name" + } +} + /// 合作获客员列表响应。 struct CooperativeSalerListPayload: Decodable { let total: Int @@ -49,6 +65,7 @@ struct CooperativeSaler: Decodable, Identifiable { let bindingId: Int let name: String let salerName: String + let photogRemarkName: String let phone: String let phoneMasked: String let salerPhone: String @@ -62,7 +79,7 @@ struct CooperativeSaler: Decodable, Identifiable { var displayId: Int { saleUserId } var displayName: String { - name.cooperationNonEmptyOrDefault(salerName) + photogRemarkName.saleUserDisplayName(primaryName: name, fallbackName: salerName) } var displayPhone: String { @@ -78,6 +95,7 @@ struct CooperativeSaler: Decodable, Identifiable { case bindingId = "binding_id" case name case salerName = "saler_name" + case photogRemarkName = "photog_remark_name" case phone case phoneMasked = "phone_masked" case salerPhone = "saler_phone" @@ -93,6 +111,7 @@ struct CooperativeSaler: Decodable, Identifiable { bindingId = try container.decodeLossyInt(forKey: .bindingId) ?? 0 name = try container.decodeLossyString(forKey: .name) salerName = try container.decodeLossyString(forKey: .salerName) + photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName) phone = try container.decodeLossyString(forKey: .phone) phoneMasked = try container.decodeLossyString(forKey: .phoneMasked) salerPhone = try container.decodeLossyString(forKey: .salerPhone) @@ -101,6 +120,52 @@ struct CooperativeSaler: Decodable, Identifiable { bindTime = try container.decodeLossyString(forKey: .bindTime) boundAt = try container.decodeLossyString(forKey: .boundAt) } + + /// 返回更新了备注名的副本,用于本地列表即时刷新。 + func withPhotogRemarkName(_ remark: String) -> CooperativeSaler { + CooperativeSaler( + saleUserId: saleUserId, + bindingId: bindingId, + name: name, + salerName: salerName, + photogRemarkName: remark, + phone: phone, + phoneMasked: phoneMasked, + salerPhone: salerPhone, + shareCode: shareCode, + storeId: storeId, + bindTime: bindTime, + boundAt: boundAt + ) + } + + init( + saleUserId: Int, + bindingId: Int, + name: String, + salerName: String, + photogRemarkName: String, + phone: String, + phoneMasked: String, + salerPhone: String, + shareCode: String, + storeId: Int, + bindTime: String, + boundAt: String + ) { + self.saleUserId = saleUserId + self.bindingId = bindingId + self.name = name + self.salerName = salerName + self.photogRemarkName = photogRemarkName + self.phone = phone + self.phoneMasked = phoneMasked + self.salerPhone = salerPhone + self.shareCode = shareCode + self.storeId = storeId + self.bindTime = bindTime + self.boundAt = boundAt + } } /// 获客员邀请信息,用于绑定确认页展示。 @@ -231,6 +296,7 @@ struct AcquisitionOrder: Decodable, Identifiable { let orderStatusName: String let status: String let saleUserName: String + let photogRemarkName: String let partnerName: String let partnerRole: String let remark: String @@ -249,7 +315,8 @@ struct AcquisitionOrder: Decodable, Identifiable { } var displayPartner: String { - if !saleUserName.isEmpty { return saleUserName } + let salerDisplayName = photogRemarkName.saleUserDisplayName(primaryName: saleUserName) + if !salerDisplayName.isEmpty { return salerDisplayName } if !partnerName.isEmpty { return partnerName } return "" } @@ -270,6 +337,7 @@ struct AcquisitionOrder: Decodable, Identifiable { case orderStatusName = "order_status_name" case status case saleUserName = "sale_user_name" + case photogRemarkName = "photog_remark_name" case partnerName = "partner_name" case partnerRole = "partner_role" case remark @@ -293,6 +361,7 @@ struct AcquisitionOrder: Decodable, Identifiable { orderStatusName = try container.decodeLossyString(forKey: .orderStatusName) status = try container.decodeLossyString(forKey: .status) saleUserName = try container.decodeLossyString(forKey: .saleUserName) + photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName) partnerName = try container.decodeLossyString(forKey: .partnerName) partnerRole = try container.decodeLossyString(forKey: .partnerRole) remark = try container.decodeLossyString(forKey: .remark) @@ -304,11 +373,17 @@ struct AcquisitionOrder: Decodable, Identifiable { struct ReferralOrderSaler: Decodable { let saleUserId: Int let salerName: String + let photogRemarkName: String let salerPhone: String + var displayName: String { + photogRemarkName.saleUserDisplayName(primaryName: salerName) + } + enum CodingKeys: String, CodingKey { case saleUserId = "sale_user_id" case salerName = "saler_name" + case photogRemarkName = "photog_remark_name" case salerPhone = "saler_phone" } @@ -316,6 +391,7 @@ struct ReferralOrderSaler: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0 salerName = try container.decodeLossyString(forKey: .salerName) + photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName) salerPhone = try container.decodeLossyString(forKey: .salerPhone) } } @@ -394,6 +470,7 @@ struct BoundReferralOrder: Decodable, Equatable, Hashable { let orderNo: String let saleUserId: Int let salerName: String + let photogRemarkName: String let salerPhone: String let displayText: String let userMobile: String @@ -405,11 +482,16 @@ struct BoundReferralOrder: Decodable, Equatable, Hashable { let statusLabel: String let createdAt: String + var displaySalerName: String { + photogRemarkName.saleUserDisplayName(primaryName: salerName) + } + enum CodingKeys: String, CodingKey { case id case orderNo = "order_no" case saleUserId = "sale_user_id" case salerName = "saler_name" + case photogRemarkName = "photog_remark_name" case salerPhone = "saler_phone" case displayText = "display_text" case userMobile = "user_mobile" @@ -428,6 +510,7 @@ struct BoundReferralOrder: Decodable, Equatable, Hashable { orderNo = try container.decodeLossyString(forKey: .orderNo) saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0 salerName = try container.decodeLossyString(forKey: .salerName) + photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName) salerPhone = try container.decodeLossyString(forKey: .salerPhone) displayText = try container.decodeLossyString(forKey: .displayText) userMobile = try container.decodeLossyString(forKey: .userMobile) diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift index f429a65..0c92f5a 100644 --- a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift +++ b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift @@ -27,4 +27,19 @@ final class CooperationAcquirerViewModel: ObservableObject { } hasLoadedOnce = true } + + /// 更新指定获客员的备注名,成功后刷新本地列表项。 + func updateRemark( + saleUserId: Int, + remark: String, + api: CooperationOrderServing + ) async throws { + guard saleUserId > 0 else { return } + let trimmedRemark = remark.trimmingCharacters(in: .whitespacesAndNewlines) + try await api.saleUserUpdateRemark(saleUserId: saleUserId, photogRemarkName: trimmedRemark) + acquirers = acquirers.map { acquirer in + guard acquirer.saleUserId == saleUserId else { return acquirer } + return acquirer.withPhotogRemarkName(trimmedRemark) + } + } } diff --git a/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift b/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift index ef3b6cd..78b5851 100644 --- a/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift +++ b/suixinkan/Features/CooperationOrder/Views/CooperationAcquirerView.swift @@ -16,6 +16,9 @@ struct CooperationAcquirerView: View { @Environment(\.openURL) private var openURL @StateObject private var viewModel = CooperationAcquirerViewModel() @State private var showScanner = false + @State private var showRemarkAlert = false + @State private var remarkEditingAcquirer: CooperativeSaler? + @State private var remarkDraft = "" let onBind: (Int) -> Void var body: some View { @@ -31,6 +34,10 @@ struct CooperationAcquirerView: View { ForEach(viewModel.acquirers, id: \.displayId) { acquirer in CooperativeSalerCard(acquirer: acquirer) { phone in dialPhone(phone) + } onEditRemark: { + remarkEditingAcquirer = acquirer + remarkDraft = acquirer.photogRemarkName + showRemarkAlert = true } } } @@ -73,6 +80,44 @@ struct CooperationAcquirerView: View { .task { await reload(showLoading: true) } + .alert("修改备注", isPresented: $showRemarkAlert) { + TextField("备注名", text: $remarkDraft) + Button("取消", role: .cancel) { + remarkEditingAcquirer = nil + } + Button("保存") { + // 必须在弹窗关闭前同步捕获,避免 alert dismiss 清空 state 导致接口未调用。 + guard let acquirer = remarkEditingAcquirer else { return } + let saleUserId = acquirer.saleUserId + let draft = remarkDraft + showRemarkAlert = false + remarkEditingAcquirer = nil + Task { + await saveRemark(saleUserId: saleUserId, draft: draft) + } + } + } message: { + if let acquirer = remarkEditingAcquirer { + Text("为 \(acquirer.salerName.cooperationNonEmptyOrDefault(acquirer.name)) 设置备注名") + } + } + } + + /// 保存当前编辑中的获客员备注名。 + private func saveRemark(saleUserId: Int, draft: String) async { + guard saleUserId > 0 else { return } + await globalLoading.withOptionalLoading(true) { + do { + try await viewModel.updateRemark( + saleUserId: saleUserId, + remark: draft, + api: cooperationOrderAPI + ) + toastCenter.show("备注已保存") + } catch { + toastCenter.show(error.localizedDescription) + } + } } private func reload(showLoading: Bool) async { diff --git a/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift b/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift index d3abcd3..d9a153c 100644 --- a/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift +++ b/suixinkan/Features/CooperationOrder/Views/CooperationOrderComponents.swift @@ -218,26 +218,37 @@ struct ReferralLeadImageGrid: View { } else { HStack(spacing: 8) { ForEach(0..<3, id: \.self) { index in - if index < images.count, let url = URL(string: images[index]) { + imageCell(at: index) + } + } + } + } + + /// 渲染单个网格位,先固定 1:1 容器再填充图片,避免 Kingfisher 按原图尺寸撑开导致格子大小不一。 + @ViewBuilder + private func imageCell(at index: Int) -> some View { + Group { + if index < images.count, let url = URL(string: images[index]) { + RoundedRectangle(cornerRadius: 6) + .fill(CooperationOrderColors.imagePlaceholderBg) + .overlay { KFImage(url) .placeholder { CooperationOrderColors.imagePlaceholderBg } .resizable() .scaledToFill() - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .contentShape(Rectangle()) - .onTapGesture { - onImageTap(images, index) - } - } else { - Color.clear - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) } - } + } else { + Color.clear } } + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + .onTapGesture { + guard index < images.count else { return } + onImageTap(images, index) + } } } @@ -290,35 +301,46 @@ struct AcquisitionOrderCard: View { struct CooperativeSalerCard: View { let acquirer: CooperativeSaler let onDial: (String) -> Void + let onEditRemark: () -> Void var body: some View { - HStack(alignment: .center, spacing: 0) { - Image(systemName: "person.fill") - .font(.system(size: 20)) - .foregroundStyle(CooperationOrderColors.primary) - .padding(10) - .background(CooperationOrderColors.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + ZStack(alignment: .topTrailing) { + HStack(alignment: .center, spacing: 0) { + Image(systemName: "person.fill") + .font(.system(size: 20)) + .foregroundStyle(CooperationOrderColors.primary) + .padding(10) + .background(CooperationOrderColors.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) - VStack(alignment: .leading, spacing: 8) { - AcquirerInfoRow( - label: "获客员名称", - value: acquirer.displayName.isEmpty ? "—" : acquirer.displayName, - emphasize: true - ) - AcquirerPhoneInfoRow(phone: acquirer.displayPhone, onDial: onDial) - AcquirerInfoRow( - label: "绑定时间", - value: acquirer.displayBindTime.isEmpty ? "—" : acquirer.displayBindTime - ) + VStack(alignment: .leading, spacing: 8) { + AcquirerInfoRow( + label: "获客员名称", + value: acquirer.displayName.isEmpty ? "—" : acquirer.displayName, + emphasize: true + ) + AcquirerPhoneInfoRow(phone: acquirer.displayPhone, onDial: onDial) + AcquirerInfoRow( + label: "绑定时间", + value: acquirer.displayBindTime.isEmpty ? "—" : acquirer.displayBindTime + ) + } + .padding(.leading, 12) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.leading, 12) + .padding(.horizontal, 14) + .padding(.vertical, 16) .frame(maxWidth: .infinity, alignment: .leading) + .background(.white, in: RoundedRectangle(cornerRadius: 10)) + .shadow(color: Color.black.opacity(0.06), radius: 1, x: 0, y: 1) + + Button("修改备注") { + onEditRemark() + } + .font(.system(size: 13)) + .foregroundStyle(CooperationOrderColors.primary) + .padding(.top, 16) + .padding(.trailing, 14) } - .padding(.horizontal, 14) - .padding(.vertical, 16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.white, in: RoundedRectangle(cornerRadius: 10)) - .shadow(color: Color.black.opacity(0.06), radius: 1, x: 0, y: 1) } } diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift index ab7230b..78ecbfb 100644 --- a/suixinkan/Features/Orders/Models/OrderModels.swift +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -89,8 +89,14 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable { let showOrderSource: Int let referralOrderNo: String let referralSalerName: String + let referralSalerPhotogRemarkName: String let referralOrder: BoundReferralOrder? + /// 订单卡片展示的获客员名称,优先备注名。 + var displayReferralSalerName: String { + referralSalerPhotogRemarkName.saleUserDisplayName(primaryName: referralSalerName) + } + enum CodingKeys: String, CodingKey { case photogUid = "photog_uid" case orderNumber = "order_number" @@ -123,6 +129,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable { case showOrderSource = "show_order_source" case referralOrderNo = "referral_order_no" case referralSalerName = "referral_saler_name" + case referralSalerPhotogRemarkName = "photog_remark_name" case referralOrder = "referral_order" } @@ -160,6 +167,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable { showOrderSource = try container.decodeLossyInt(forKey: .showOrderSource) ?? 0 referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo) referralSalerName = try container.decodeLossyString(forKey: .referralSalerName) + referralSalerPhotogRemarkName = try container.decodeLossyString(forKey: .referralSalerPhotogRemarkName) referralOrder = try container.decodeIfPresent(BoundReferralOrder.self, forKey: .referralOrder) } } diff --git a/suixinkan/Features/Orders/Models/OrderSourceModels.swift b/suixinkan/Features/Orders/Models/OrderSourceModels.swift index 96ca427..91932d5 100644 --- a/suixinkan/Features/Orders/Models/OrderSourceModels.swift +++ b/suixinkan/Features/Orders/Models/OrderSourceModels.swift @@ -52,12 +52,13 @@ extension ReferralOrder { } extension BoundReferralOrder { - func toOrderSourceSelection(fallbackSalerName: String = "") -> OrderSourceSelection? { + func toOrderSourceSelection(fallbackSalerName: String = "", fallbackPhotogRemarkName: String = "") -> OrderSourceSelection? { let referralNo = orderNo.trimmingCharacters(in: .whitespacesAndNewlines) guard !referralNo.isEmpty else { return nil } - let personName = salerName.isEmpty - ? (fallbackSalerName.isEmpty ? "—" : fallbackSalerName) - : salerName + let fallbackDisplayName = fallbackPhotogRemarkName.saleUserDisplayName(primaryName: fallbackSalerName) + let personName = displaySalerName.isEmpty + ? (fallbackDisplayName.isEmpty ? "—" : fallbackDisplayName) + : displaySalerName let remarkText: String if !remark.isEmpty { remarkText = remark @@ -85,14 +86,18 @@ extension BoundReferralOrder { extension OrderEntity { func toBoundOrderSourceSelection() -> OrderSourceSelection? { if let referralOrder, - let selection = referralOrder.toOrderSourceSelection(fallbackSalerName: referralSalerName) { + let selection = referralOrder.toOrderSourceSelection( + fallbackSalerName: referralSalerName, + fallbackPhotogRemarkName: referralSalerPhotogRemarkName + ) { return selection } guard !referralOrderNo.isEmpty else { return nil } + let personName = displayReferralSalerName.isEmpty ? "—" : displayReferralSalerName return OrderSourceSelection( person: OrderSourcePerson( key: "", - name: referralSalerName.isEmpty ? "—" : referralSalerName, + name: personName, phone: "" ), lead: OrderSourceLead( diff --git a/suixinkan/Features/ScenicPromotion/ScenicPromotion.md b/suixinkan/Features/ScenicPromotion/ScenicPromotion.md index 6f2d826..8878fdb 100644 --- a/suixinkan/Features/ScenicPromotion/ScenicPromotion.md +++ b/suixinkan/Features/ScenicPromotion/ScenicPromotion.md @@ -37,7 +37,7 @@ flowchart TD ## 主页面规则 -1. 打卡点由接口返回列表动态展示;Mock 含网红桥、汉地拔冲、空中草原、河谷花海、雪山栈道。 +1. 打卡点由接口返回列表动态展示;Mock 含网红桥、汉地拔冲、空中草原、河谷花海、雪山栈道、猎鹰台、雪莲谷、游牧人家。 2. 5 个以内等分展示,超过 5 个时一屏露出 5 个并横向滚动。 3. 缩略图点击只联动底部主视觉,不跳转二级页。 4. 视频主视觉点击仅展示模拟播放提示。 @@ -75,5 +75,8 @@ flowchart TD - `Models/ScenicPromotionModels.swift` — 状态枚举与数据实体 - `Services/ScenicPromotionMockStore.swift` — Mock 种子数据 - `ViewModels/ScenicPromotionViewModels.swift` — 主页面与设置页 ViewModel -- `Views/ScenicPromotionViews.swift` — 主页面 -- `Views/ScenicPromotionSettingsViews.swift` — 设置页与素材预览 +- `Views/ScenicPromotionView.swift` — 主页面入口 +- `Views/ScenicPromotionContentView.swift` — 主页面内容组装 +- `Views/ScenicPromotionSettingsView.swift` — 设置页入口 +- `Views/ScenicPromotionPreviewSupport.swift` — Preview 工厂(DEBUG) +- 其余 `Views/ScenicPromotion*.swift` — 各业务子视图,均含 `#Preview` diff --git a/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift b/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift index 2d83030..fd07fd1 100644 --- a/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift +++ b/suixinkan/Features/ScenicPromotion/Services/ScenicPromotionMockStore.swift @@ -286,6 +286,189 @@ enum ScenicPromotionMockStore { previewWidth: 96 ) ] + ), + ScenicPromotionSpot( + id: "eagle", + name: "猎鹰台", + iconName: "binoculars.fill", + queue: ScenicPromotionQueueInfo( + waitingPeople: 41, + estimatedMinutes: 14, + averageMinutes: 13, + nextNumber: "E042", + currentNumber: "E041", + shootingDuration: "8分钟50秒" + ), + videos: [ + ScenicPromotionMedia( + id: "eagle_video_1", + mode: .video, + title: "猎鹰台", + subtitle: "俯瞰草原与河谷全景", + duration: "00:30", + palette: [0x0B78F6, 0xB4E8FF, 0x4E9D3B], + assetName: "ScenicPromotionThumbMarker", + heroAssetName: "ScenicPromotionHero", + previewWidth: 144 + ), + ScenicPromotionMedia( + id: "eagle_video_2", + mode: .video, + title: "日落观景", + subtitle: "金色余晖下的草原层次", + duration: "00:27", + palette: [0xE8A030, 0xFFE8A8, 0x4AAE57], + assetName: "ScenicPromotionThumbGrass", + heroAssetName: nil, + previewWidth: 72 + ) + ], + photos: [ + ScenicPromotionMedia( + id: "eagle_photo_1", + mode: .photo, + title: "全景远眺", + subtitle: "猎鹰台俯瞰草原与远山", + duration: nil, + palette: [0x167CFF, 0xBDEBFF, 0x58B15D], + assetName: "ScenicPromotionThumbMarker", + heroAssetName: nil, + previewWidth: 144 + ), + ScenicPromotionMedia( + id: "eagle_photo_2", + mode: .photo, + title: "日落剪影", + subtitle: "游客与落日同框", + duration: nil, + palette: [0xE8A030, 0xFFE8A8, 0x4AAE57], + assetName: "ScenicPromotionThumbPortrait", + heroAssetName: nil, + previewWidth: 72 + ) + ] + ), + ScenicPromotionSpot( + id: "snowlotus", + name: "雪莲谷", + iconName: "water.waves", + queue: ScenicPromotionQueueInfo( + waitingPeople: 15, + estimatedMinutes: 7, + averageMinutes: 11, + nextNumber: "L016", + currentNumber: "L015", + shootingDuration: "6分钟30秒" + ), + videos: [ + ScenicPromotionMedia( + id: "snowlotus_video_1", + mode: .video, + title: "雪莲谷", + subtitle: "溪流、草甸与雪山倒影", + duration: "00:25", + palette: [0x1D8BFF, 0x8FD7FF, 0x2F8F45], + assetName: "ScenicPromotionThumbGrass", + heroAssetName: nil, + previewWidth: 96 + ) + ], + photos: [ + ScenicPromotionMedia( + id: "snowlotus_photo_1", + mode: .photo, + title: "溪流倒影", + subtitle: "清澈溪水与雪山同框", + duration: nil, + palette: [0x1D8BFF, 0x8FD7FF, 0x2F8F45], + assetName: "ScenicPromotionThumbGrass", + heroAssetName: nil, + previewWidth: 96 + ), + ScenicPromotionMedia( + id: "snowlotus_photo_2", + mode: .photo, + title: "谷地花海", + subtitle: "溪流旁野花点缀", + duration: nil, + palette: [0x3CA7FF, 0xC8F1FF, 0x4AAE57], + assetName: "ScenicPromotionThumbPortrait", + heroAssetName: nil, + previewWidth: 72 + ) + ] + ), + ScenicPromotionSpot( + id: "nomad", + name: "游牧人家", + iconName: "tent.fill", + queue: ScenicPromotionQueueInfo( + waitingPeople: 36, + estimatedMinutes: 13, + averageMinutes: 15, + nextNumber: "N037", + currentNumber: "N036", + shootingDuration: "9分钟40秒" + ), + videos: [ + ScenicPromotionMedia( + id: "nomad_video_1", + mode: .video, + title: "游牧人家", + subtitle: "毡房、牛羊与草原生活", + duration: "00:31", + palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D], + assetName: "ScenicPromotionThumbHorse", + heroAssetName: nil, + previewWidth: 96 + ), + ScenicPromotionMedia( + id: "nomad_video_2", + mode: .video, + title: "毡房旅拍", + subtitle: "民族服饰与草原背景", + duration: "00:23", + palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C], + assetName: "ScenicPromotionThumbPortrait", + heroAssetName: nil, + previewWidth: 72 + ) + ], + photos: [ + ScenicPromotionMedia( + id: "nomad_photo_1", + mode: .photo, + title: "毡房合影", + subtitle: "传统毡房前的旅拍样片", + duration: nil, + palette: [0x246BDF, 0x9BD9FF, 0x7EAC3D], + assetName: "ScenicPromotionThumbHorse", + heroAssetName: nil, + previewWidth: 96 + ), + ScenicPromotionMedia( + id: "nomad_photo_2", + mode: .photo, + title: "民族服饰", + subtitle: "游客身着民族服装打卡", + duration: nil, + palette: [0x1B7DFF, 0xA9E8FF, 0xF5D36C], + assetName: "ScenicPromotionThumbPortrait", + heroAssetName: nil, + previewWidth: 72 + ), + ScenicPromotionMedia( + id: "nomad_photo_3", + mode: .photo, + title: "草原牧群", + subtitle: "牛羊与远山层次构图", + duration: nil, + palette: [0x2A7BEF, 0xB7E5FF, 0x74B84A], + assetName: "ScenicPromotionThumbGrass", + heroAssetName: nil, + previewWidth: 144 + ) + ] ) ] ) diff --git a/suixinkan/Features/ScenicPromotion/ViewModels/ScenicPromotionViewModels.swift b/suixinkan/Features/ScenicPromotion/ViewModels/ScenicPromotionViewModels.swift index 20523ed..0a44e7d 100644 --- a/suixinkan/Features/ScenicPromotion/ViewModels/ScenicPromotionViewModels.swift +++ b/suixinkan/Features/ScenicPromotion/ViewModels/ScenicPromotionViewModels.swift @@ -140,6 +140,8 @@ final class ScenicPromotionSettingsViewModel: ObservableObject { @Published var selectedQueuePointIDs: Set = [] @Published var selectedMaterialPointID: String? @Published private(set) var materialsByPointID: [String: [ScenicPromotionManagedMaterial]] = [:] + /// 当前要预览的已上传素材。 + @Published var previewMaterial: ScenicPromotionManagedMaterial? /// 多选排队打卡点的展示文案。 var selectedQueuePointNames: String { @@ -262,6 +264,11 @@ final class ScenicPromotionSettingsViewModel: ObservableObject { } } + /// 打开素材预览页。 + func openMaterialPreview(_ material: ScenicPromotionManagedMaterial) { + previewMaterial = material + } + /// 提交前校验至少绑定一个排队打卡点。 func submit() -> Bool { guard !selectedQueuePointIDs.isEmpty else { return false } diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionBackgroundViews.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionBackgroundViews.swift new file mode 100644 index 0000000..d3ed371 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionBackgroundViews.swift @@ -0,0 +1,66 @@ +// +// ScenicPromotionBackgroundViews.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页全屏背景,叠加轻量蓝绿色遮罩以贴近参考图。 +struct ScenicPromotionSkyBackground: View { + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .bottom) { + Image("ScenicPromotionBackground") + .resizable() + .scaledToFill() + // 先限定为容器尺寸,再裁剪超出部分(scaledToFill 会放大图片)。 + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + + 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) + } + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + } + } +} + +/// 顶部二维码图片,快速点击三次进入设置页密码弹窗。 +struct ScenicPromotionQRCodeView: View { + var body: some View { + Image("ScenicPromotionQRCode") + .resizable() + .scaledToFit() + } +} + +#if DEBUG +#Preview("宣传页背景") { + ScenicPromotionSkyBackground() + .ignoresSafeArea() +} + +#Preview("宣传页二维码") { + ScenicPromotionQRCodeView() + .frame(width: 56, height: 56) + .padding() + .background(.white) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionContentView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionContentView.swift new file mode 100644 index 0000000..c04a0c2 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionContentView.swift @@ -0,0 +1,71 @@ +// +// ScenicPromotionContentView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页正常内容区,组装各业务子模块。 +struct ScenicPromotionContentView: View { + @ObservedObject var viewModel: ScenicPromotionViewModel + let onQRCodeTap: () -> Void + let onQRCodeLongPress: () -> Void + let onVideoTap: () -> Void + + var body: some View { + VStack(spacing: ScenicPromotionLayout.moduleVerticalSpacing) { + if let board = viewModel.board { + ScenicPromotionHeaderView( + board: board, + onQRCodeTap: onQRCodeTap, + onQRCodeLongPress: onQRCodeLongPress + ) + ScenicPromotionSpotTabsView( + spots: board.spots, + selectedSpotID: viewModel.selectedSpot?.id, + onSelectSpot: viewModel.selectSpot + ) + ScenicPromotionMetricsGridView(queue: viewModel.queue) + ScenicPromotionQueuePanelView(queue: viewModel.queue) + ScenicPromotionMediaToolbarView( + selectedMode: viewModel.selectedMode, + onSelectMode: viewModel.selectMode + ) + ScenicPromotionMediaStripView( + mediaItems: viewModel.mediaItems, + selectedMediaID: viewModel.selectedMedia?.id, + emptyMode: viewModel.selectedMode, + onSelectMedia: viewModel.selectMedia, + onMoveMedia: viewModel.moveMedia + ) + ScenicPromotionHeroMediaView( + selectedMedia: viewModel.selectedMedia, + emptyMode: viewModel.selectedMode, + onVideoTap: onVideoTap + ) + } + } + .border(.blue) + .padding(.top, 28) + .padding(.bottom, 24) + .border(.red) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, ScenicPromotionLayout.screenEdgePadding) + } +} + +#if DEBUG +#Preview("宣传页内容") { + ZStack { + ScenicPromotionSkyBackground().ignoresSafeArea() + ScenicPromotionContentView( + viewModel: ScenicPromotionPreviewSupport.mainViewModel(), + onQRCodeTap: {}, + onQRCodeLongPress: {}, + onVideoTap: {} + ) + } +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionFullStateView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionFullStateView.swift new file mode 100644 index 0000000..693d83d --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionFullStateView.swift @@ -0,0 +1,65 @@ +// +// ScenicPromotionFullStateView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页 Empty / Error 通用状态视图。 +struct ScenicPromotionFullStateView: View { + let icon: String + let title: String + let message: String + let buttonTitle: String + let action: () -> Void + + var body: 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) + } +} + +#if DEBUG +#Preview("空数据") { + ZStack { + ScenicPromotionSkyBackground().ignoresSafeArea() + ScenicPromotionFullStateView( + icon: "doc.text.image", + title: "暂无宣传页数据", + message: "当前景区还没有配置排队导览宣传内容", + buttonTitle: "重新加载" + ) {} + } +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeaderView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeaderView.swift new file mode 100644 index 0000000..aca6c5c --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeaderView.swift @@ -0,0 +1,71 @@ +// +// ScenicPromotionHeaderView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页顶部标题、说明文案和二维码入口。 +struct ScenicPromotionHeaderView: View { + let board: ScenicPromotionBoard + let onQRCodeTap: () -> Void + let onQRCodeLongPress: () -> Void + + var body: 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(action: onQRCodeTap) { + 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 onQRCodeLongPress() } + ) + .accessibilityLabel("二维码设置入口") + } + .frame(height: 96) + } +} + +#if DEBUG +#Preview("顶部标题区") { + ScenicPromotionHeaderView( + board: ScenicPromotionPreviewSupport.sampleBoard, + onQRCodeTap: {}, + onQRCodeLongPress: {} + ) + .padding(.horizontal, 20) + .background(ScenicPromotionSkyBackground()) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeroMediaView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeroMediaView.swift new file mode 100644 index 0000000..986d3f8 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionHeroMediaView.swift @@ -0,0 +1,96 @@ +// +// ScenicPromotionHeroMediaView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页底部主视觉区域,展示当前选中的视频或图片。 +struct ScenicPromotionHeroMediaView: View { + let selectedMedia: ScenicPromotionMedia? + let emptyMode: ScenicPromotionMediaMode + let onVideoTap: () -> Void + + var body: some View { + Group { + if let media = selectedMedia { + Button { + if media.mode == .video { + onVideoTap() + } + } label: { + ZStack(alignment: .bottomLeading) { + ScenicPromotionMediaArtwork(media: media, compact: false, useHeroAsset: true) + .frame(maxWidth: .infinity) + .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 { + ScenicPromotionMediaEmptyView(mode: emptyMode) + .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) + ) + } + } + } +} + +#if DEBUG +#Preview("主视觉") { + ScenicPromotionHeroMediaView( + selectedMedia: ScenicPromotionPreviewSupport.sampleVideoMedia, + emptyMode: .video, + onVideoTap: {} + ) + .padding(.horizontal, 20) +} + +#Preview("主视觉-空") { + ScenicPromotionHeroMediaView( + selectedMedia: nil, + emptyMode: .video, + onVideoTap: {} + ) + .padding(.horizontal, 20) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLayout.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLayout.swift new file mode 100644 index 0000000..1e5f8d3 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLayout.swift @@ -0,0 +1,24 @@ +// +// ScenicPromotionLayout.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import CoreGraphics +import Foundation + +/// 宣传页布局常量,统一管理边距与模块尺寸。 +enum ScenicPromotionLayout { + /// 主页面内容距离屏幕左右边缘各 20px,不代表模块之间的间距。 + static let screenEdgePadding: CGFloat = 20 + /// 模块之间保持轻量垂直间距,避免误用 20px 做模块间隔。 + static let moduleVerticalSpacing: CGFloat = 12 + /// 打卡点标签区最多一屏露出 5 个,更多时横向滚动。 + static let visibleSpotTabCount: CGFloat = 5 +} + +/// 宣传页隐藏设置页路由实体。 +struct ScenicPromotionSettingsRoute: Identifiable, Hashable { + let id = "promotion_page_settings" +} diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLoadingView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLoadingView.swift new file mode 100644 index 0000000..9fdccd0 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionLoadingView.swift @@ -0,0 +1,52 @@ +// +// ScenicPromotionLoadingView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页 Loading 骨架屏,尺寸跟正常模块保持一致。 +struct ScenicPromotionLoadingView: View { + var body: 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) + .frame(maxWidth: .infinity, alignment: .top) + } + } +} + +#if DEBUG +#Preview("加载中") { + ZStack { + ScenicPromotionSkyBackground().ignoresSafeArea() + ScenicPromotionLoadingView() + } +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMaterialPreviewView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMaterialPreviewView.swift new file mode 100644 index 0000000..e8c9421 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMaterialPreviewView.swift @@ -0,0 +1,89 @@ +// +// ScenicPromotionMaterialPreviewView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 设置页素材预览视图。 +struct ScenicPromotionMaterialPreviewView: View { + 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() + + ScenicPromotionMaterialPreviewInfoRow(title: "素材类型", value: material.kind.shortTitle) + ScenicPromotionMaterialPreviewInfoRow(title: "上传时间", value: material.createdAt) + if let duration = material.duration { + ScenicPromotionMaterialPreviewInfoRow(title: "视频时长", value: duration) + } + } + .padding(16) + .background(.white, in: RoundedRectangle(cornerRadius: 16)) + } + .padding(16) + } + .background(AppDesign.background.ignoresSafeArea()) + .navigationTitle(material.kind == .video ? "视频预览" : "图片预览") + .navigationBarTitleDisplayMode(.inline) + } +} + +/// 素材预览详情中的单行字段。 +struct ScenicPromotionMaterialPreviewInfoRow: View { + let title: String + let value: String + + var body: 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) + } + } +} + +#if DEBUG +#Preview("视频预览") { + NavigationStack { + ScenicPromotionMaterialPreviewView( + material: ScenicPromotionPreviewSupport.sampleManagedMaterial + ) + } +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaArtworkView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaArtworkView.swift new file mode 100644 index 0000000..8f14a94 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaArtworkView.swift @@ -0,0 +1,100 @@ +// +// ScenicPromotionMediaArtworkView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页素材图片渲染组件,支持缩略图与主视觉两种尺寸。 +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)) + } +} + +/// 没有本地图片资源时使用的备用山形占位图。 +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 + } +} + +#if DEBUG +#Preview("素材缩略图") { + ScenicPromotionMediaArtwork( + media: ScenicPromotionPreviewSupport.sampleVideoMedia, + compact: true, + useHeroAsset: false + ) + .frame(width: 72, height: 66) + .padding() +} + +#Preview("素材主视觉") { + ScenicPromotionMediaArtwork( + media: ScenicPromotionPreviewSupport.sampleVideoMedia, + compact: false, + useHeroAsset: true + ) + .frame(maxWidth: .infinity) + .frame(height: 220) + .padding() +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaEmptyView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaEmptyView.swift new file mode 100644 index 0000000..0713154 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaEmptyView.swift @@ -0,0 +1,44 @@ +// +// ScenicPromotionMediaEmptyView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页当前视频 / 照片模式没有素材时展示的空状态。 +struct ScenicPromotionMediaEmptyView: View { + let mode: ScenicPromotionMediaMode + + var body: some View { + VStack(spacing: 12) { + Image(systemName: mode == .video ? "play.rectangle.fill" : "photo.on.rectangle.angled") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(Color(hex: 0x57A2FF)) + Text(mode == .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) + } +} + +#if DEBUG +#Preview("素材空状态-视频") { + ScenicPromotionMediaEmptyView(mode: .video) + .padding() + .background(.white.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) + .padding() +} + +#Preview("素材空状态-照片") { + ScenicPromotionMediaEmptyView(mode: .photo) + .padding() + .background(.white.opacity(0.72), in: RoundedRectangle(cornerRadius: 18)) + .padding() +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaStripView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaStripView.swift new file mode 100644 index 0000000..70947f7 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaStripView.swift @@ -0,0 +1,98 @@ +// +// ScenicPromotionMediaStripView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页素材横向选择器,支持左右切换与缩略图选中。 +struct ScenicPromotionMediaStripView: View { + let mediaItems: [ScenicPromotionMedia] + let selectedMediaID: String? + let emptyMode: ScenicPromotionMediaMode + let onSelectMedia: (ScenicPromotionMedia) -> Void + let onMoveMedia: (Int) -> Void + + var body: some View { + HStack(spacing: 8) { + Button { + onMoveMedia(-1) + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 22, weight: .heavy)) + .foregroundStyle(Color(hex: 0x326EAE)) + .frame(width: 28, height: 100) + } + .buttonStyle(.plain) + + if mediaItems.isEmpty { + ScenicPromotionMediaEmptyView(mode: emptyMode) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(mediaItems) { media in + Button { + onSelectMedia(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( + selectedMediaID == media.id ? Color(hex: 0x0B79FF) : .white.opacity(0.75), + lineWidth: selectedMediaID == media.id ? 3 : 1 + ) + ) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 2) + } + } + + Button { + onMoveMedia(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) + ) + } +} + +#if DEBUG +#Preview("素材横向列表") { + let board = ScenicPromotionPreviewSupport.sampleBoard + ScenicPromotionMediaStripView( + mediaItems: board.spots[0].videos, + selectedMediaID: board.spots[0].videos[0].id, + emptyMode: .video, + onSelectMedia: { _ in }, + onMoveMedia: { _ in } + ) + .padding(.horizontal, 20) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaToolbarView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaToolbarView.swift new file mode 100644 index 0000000..31e4c57 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMediaToolbarView.swift @@ -0,0 +1,59 @@ +// +// ScenicPromotionMediaToolbarView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页视频 / 照片切换工具条。 +struct ScenicPromotionMediaToolbarView: View { + let selectedMode: ScenicPromotionMediaMode + let onSelectMode: (ScenicPromotionMediaMode) -> Void + + var body: some View { + HStack(spacing: 14) { + HStack(spacing: 0) { + ForEach(ScenicPromotionMediaMode.allCases) { mode in + Button { + onSelectMode(mode) + } label: { + HStack(spacing: 6) { + Image(systemName: mode.iconName) + Text(mode.title) + } + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(selectedMode == mode ? .white : Color(hex: 0x1B4D91)) + .frame(width: 68, height: 34) + .background( + 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) + } + } +} + +#if DEBUG +#Preview("素材切换工具条") { + ScenicPromotionMediaToolbarView(selectedMode: .video, onSelectMode: { _ in }) + .padding(.horizontal, 20) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMetricsGridView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMetricsGridView.swift new file mode 100644 index 0000000..9171ad7 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionMetricsGridView.swift @@ -0,0 +1,69 @@ +// +// ScenicPromotionMetricsGridView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页三个排队数据卡片:当前排队人数、预计等待时间、平均等待时长。 +struct ScenicPromotionMetricsGridView: View { + let queue: ScenicPromotionQueueInfo? + + var body: some View { + 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)) + ] + + 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) + ) + } + } + } +} + +#if DEBUG +#Preview("排队数据") { + ScenicPromotionMetricsGridView(queue: ScenicPromotionPreviewSupport.sampleQueue) + .padding(.horizontal, 20) +} + +#Preview("排队数据-空") { + ScenicPromotionMetricsGridView(queue: nil) + .padding(.horizontal, 20) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionPreviewSupport.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionPreviewSupport.swift new file mode 100644 index 0000000..6e23522 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionPreviewSupport.swift @@ -0,0 +1,48 @@ +// +// ScenicPromotionPreviewSupport.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import Foundation + +#if DEBUG +/// 宣传页 Preview 工厂,提供预置 ViewModel 与 Mock 数据。 +@MainActor +enum ScenicPromotionPreviewSupport { + /// 返回指定演示场景的主页面 ViewModel。 + static func mainViewModel(scenario: ScenicPromotionDemoScenario = .normal) -> ScenicPromotionViewModel { + let viewModel = ScenicPromotionViewModel() + viewModel.applyScenario(scenario) + return viewModel + } + + /// 返回指定演示场景的设置页 ViewModel。 + static func settingsViewModel(scenario: ScenicPromotionSettingsDemoScenario = .normal) -> ScenicPromotionSettingsViewModel { + let viewModel = ScenicPromotionSettingsViewModel() + viewModel.applyScenario(scenario) + return viewModel + } + + /// 预览用看板数据。 + static var sampleBoard: ScenicPromotionBoard { + ScenicPromotionMockStore.mockBoard + } + + /// 预览用排队信息。 + static var sampleQueue: ScenicPromotionQueueInfo { + sampleBoard.spots[0].queue! + } + + /// 预览用视频素材。 + static var sampleVideoMedia: ScenicPromotionMedia { + sampleBoard.spots[0].videos[0] + } + + /// 预览用已上传素材。 + static var sampleManagedMaterial: ScenicPromotionManagedMaterial { + ScenicPromotionMockStore.mockMaterialsByPointID["material_bridge"]!.first! + } +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionQueuePanelView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionQueuePanelView.swift new file mode 100644 index 0000000..babecbe --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionQueuePanelView.swift @@ -0,0 +1,118 @@ +// +// ScenicPromotionQueuePanelView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页当前叫号模块,有排队数据时展示叫号,无排队时展示空状态。 +struct ScenicPromotionQueuePanelView: View { + let queue: ScenicPromotionQueueInfo? + + var body: some View { + Group { + if let queue { + HStack(spacing: 10) { + ScenicPromotionQueueSideView( + 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) + ScenicPromotionQueueSideView( + 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) + } +} + +/// 叫号模块左右两侧的小信息块。 +struct ScenicPromotionQueueSideView: View { + let icon: String + let title: String + let value: String + let tint: Color + + var body: 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) + } +} + +#if DEBUG +#Preview("叫号面板") { + ScenicPromotionQueuePanelView(queue: ScenicPromotionPreviewSupport.sampleQueue) + .padding(.horizontal, 20) +} + +#Preview("叫号面板-空") { + ScenicPromotionQueuePanelView(queue: nil) + .padding(.horizontal, 20) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsContentView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsContentView.swift new file mode 100644 index 0000000..f3d06ff --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsContentView.swift @@ -0,0 +1,47 @@ +// +// ScenicPromotionSettingsContentView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 设置页正常内容区域。 +struct ScenicPromotionSettingsContentView: View { + @ObservedObject var viewModel: ScenicPromotionSettingsViewModel + let onUploadFailed: () -> Void + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: 16) { + ScenicPromotionSettingsHeaderView() + ScenicPromotionSettingsQueueBindingCard(viewModel: viewModel) + ScenicPromotionSettingsMaterialPointCard(viewModel: viewModel) + ScenicPromotionSettingsMaterialUploadCard( + viewModel: viewModel, + kind: .video, + onUploadFailed: onUploadFailed + ) + ScenicPromotionSettingsMaterialUploadCard( + viewModel: viewModel, + kind: .image, + onUploadFailed: onUploadFailed + ) + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 28) + } + } +} + +#if DEBUG +#Preview("设置页内容") { + ScenicPromotionSettingsContentView( + viewModel: ScenicPromotionPreviewSupport.settingsViewModel(), + onUploadFailed: {} + ) + .background(AppDesign.background) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsHeaderView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsHeaderView.swift new file mode 100644 index 0000000..027e503 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsHeaderView.swift @@ -0,0 +1,51 @@ +// +// ScenicPromotionSettingsHeaderView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 设置页顶部说明模块。 +struct ScenicPromotionSettingsHeaderView: View { + var body: 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) + ) + } +} + +#if DEBUG +#Preview("设置页头部") { + ScenicPromotionSettingsHeaderView() + .padding() + .background(AppDesign.background) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialPointCard.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialPointCard.swift new file mode 100644 index 0000000..6c02bc7 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialPointCard.swift @@ -0,0 +1,52 @@ +// +// ScenicPromotionSettingsMaterialPointCard.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 设置页素材所属打卡点选择卡片。 +struct ScenicPromotionSettingsMaterialPointCard: View { + @ObservedObject var viewModel: ScenicPromotionSettingsViewModel + + var body: some View { + ScenicPromotionSettingsCard( + 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: { + ScenicPromotionSettingsSelectionField( + title: viewModel.selectedMaterialPointName, + subtitle: viewModel.selectedMaterialPoint == nil ? "未选择" : "已展示当前点位素材", + icon: "chevron.down" + ) + } + .buttonStyle(.plain) + } + } +} + +#if DEBUG +#Preview("素材点卡片") { + ScenicPromotionSettingsMaterialPointCard( + viewModel: ScenicPromotionPreviewSupport.settingsViewModel() + ) + .padding() + .background(AppDesign.background) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialRowView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialRowView.swift new file mode 100644 index 0000000..438b462 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialRowView.swift @@ -0,0 +1,69 @@ +// +// ScenicPromotionSettingsMaterialRowView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 设置页已上传素材行,支持预览与删除。 +struct ScenicPromotionSettingsMaterialRowView: View { + let material: ScenicPromotionManagedMaterial + let onPreview: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack(spacing: 10) { + Button(action: onPreview) { + HStack(spacing: 12) { + ScenicPromotionSettingsMaterialThumbnail(material: 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(action: onDelete) { + 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)) + } +} + +#if DEBUG +#Preview("素材行") { + ScenicPromotionSettingsMaterialRowView( + material: ScenicPromotionPreviewSupport.sampleManagedMaterial, + onPreview: {}, + onDelete: {} + ) + .padding() + .background(AppDesign.background) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialUploadCard.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialUploadCard.swift new file mode 100644 index 0000000..062ef91 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsMaterialUploadCard.swift @@ -0,0 +1,87 @@ +// +// ScenicPromotionSettingsMaterialUploadCard.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 设置页视频 / 图片上传模块。 +struct ScenicPromotionSettingsMaterialUploadCard: View { + @ObservedObject var viewModel: ScenicPromotionSettingsViewModel + let kind: ScenicPromotionUploadKind + let onUploadFailed: () -> Void + + var body: some View { + let materials = viewModel.materials(for: kind) + + ScenicPromotionSettingsCard( + 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 { + guard viewModel.addMaterial(kind: kind) else { + onUploadFailed() + return + } + } 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 { + ScenicPromotionSettingsInlineNotice( + text: "请选择素材所属打卡点后再上传\(kind.shortTitle)", + tint: AppDesign.warning + ) + } else if materials.isEmpty { + ScenicPromotionSettingsMaterialEmptyView(kind: kind) + } else { + VStack(spacing: 10) { + ForEach(materials) { material in + ScenicPromotionSettingsMaterialRowView( + material: material, + onPreview: { viewModel.openMaterialPreview(material) }, + onDelete: { viewModel.deleteMaterial(material) } + ) + } + } + } + } + } +} + +#if DEBUG +#Preview("视频上传卡片") { + ScenicPromotionSettingsMaterialUploadCard( + viewModel: ScenicPromotionPreviewSupport.settingsViewModel(), + kind: .video, + onUploadFailed: {} + ) + .padding() + .background(AppDesign.background) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsQueueBindingCard.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsQueueBindingCard.swift new file mode 100644 index 0000000..3c9ee0b --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsQueueBindingCard.swift @@ -0,0 +1,59 @@ +// +// ScenicPromotionSettingsQueueBindingCard.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 设置页排队打卡点绑定卡片,支持多选。 +struct ScenicPromotionSettingsQueueBindingCard: View { + @ObservedObject var viewModel: ScenicPromotionSettingsViewModel + + var body: some View { + ScenicPromotionSettingsCard( + 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: { + ScenicPromotionSettingsSelectionField( + title: viewModel.selectedQueuePointNames, + subtitle: "\(viewModel.selectedQueuePointIDs.count) 个已选择", + icon: "chevron.down" + ) + } + .buttonStyle(.plain) + + if viewModel.selectedQueuePointIDs.isEmpty { + ScenicPromotionSettingsInlineNotice( + text: "提交前至少选择一个排队打卡点", + tint: AppDesign.warning + ) + } + } + } +} + +#if DEBUG +#Preview("排队绑定卡片") { + ScenicPromotionSettingsQueueBindingCard( + viewModel: ScenicPromotionPreviewSupport.settingsViewModel() + ) + .padding() + .background(AppDesign.background) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSharedViews.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSharedViews.swift new file mode 100644 index 0000000..b6872f9 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSharedViews.swift @@ -0,0 +1,234 @@ +// +// ScenicPromotionSettingsSharedViews.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 设置页通用卡片容器。 +struct ScenicPromotionSettingsCard: View { + let title: String + let subtitle: String + let icon: String + let tint: Color + @ViewBuilder let content: Content + + var body: 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) + ) + } +} + +/// 设置页下拉选择框样式。 +struct ScenicPromotionSettingsSelectionField: View { + let title: String + let subtitle: String + let icon: String + + var body: 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) + ) + } +} + +/// 设置页表单内联提示。 +struct ScenicPromotionSettingsInlineNotice: View { + let text: String + let tint: Color + + var body: 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)) + } +} + +/// 设置页某个素材点当前没有素材时的空状态。 +struct ScenicPromotionSettingsMaterialEmptyView: View { + let kind: ScenicPromotionUploadKind + + var body: 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)) + } +} + +/// 设置页素材行缩略图。 +struct ScenicPromotionSettingsMaterialThumbnail: View { + let material: ScenicPromotionManagedMaterial + + var body: 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 骨架屏。 +struct ScenicPromotionSettingsLoadingView: View { + var body: 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 通用状态视图。 +struct ScenicPromotionSettingsFullStateView: View { + let icon: String + let title: String + let message: String + let buttonTitle: String + let action: () -> Void + + var body: 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) + } +} + +#if DEBUG +#Preview("设置卡片") { + ScenicPromotionSettingsCard( + title: "排队打卡点绑定", + subtitle: "可多选,用于宣传页展示排队动态", + icon: "checklist.checked", + tint: AppDesign.primary + ) { + ScenicPromotionSettingsSelectionField( + title: "网红桥排队点、汉地拔冲排队点", + subtitle: "2 个已选择", + icon: "chevron.down" + ) + } + .padding() + .background(AppDesign.background) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSubmitBarView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSubmitBarView.swift new file mode 100644 index 0000000..b05a508 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsSubmitBarView.swift @@ -0,0 +1,38 @@ +// +// ScenicPromotionSettingsSubmitBarView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 设置页底部悬浮提交栏。 +struct ScenicPromotionSettingsSubmitBarView: View { + let onSubmit: () -> Void + + var body: some View { + VStack(spacing: 0) { + Divider() + Button(action: onSubmit) { + 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) + } +} + +#if DEBUG +#Preview("提交栏") { + ScenicPromotionSettingsSubmitBarView(onSubmit: {}) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsView.swift new file mode 100644 index 0000000..e7b9b94 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsView.swift @@ -0,0 +1,113 @@ +// +// ScenicPromotionSettingsView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页设置视图,管理排队绑定、素材点与 Mock 上传。 +struct ScenicPromotionSettingsView: View { + @StateObject private var viewModel = ScenicPromotionSettingsViewModel() + @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: + ScenicPromotionSettingsLoadingView() + .transition(.opacity.combined(with: .scale(scale: 0.98))) + case .empty: + ScenicPromotionSettingsFullStateView( + icon: "tray", + title: "暂无设置数据", + message: "当前没有可配置的排队打卡点和素材打卡点", + buttonTitle: "重新加载" + ) { + Task { await viewModel.load() } + } + .transition(.opacity.combined(with: .move(edge: .bottom))) + case .error(let message): + ScenicPromotionSettingsFullStateView( + icon: "wifi.exclamationmark", + title: "加载失败", + message: message, + buttonTitle: "重试" + ) { + Task { await viewModel.load() } + } + .transition(.opacity.combined(with: .move(edge: .bottom))) + case .normal: + ScenicPromotionSettingsContentView( + viewModel: viewModel, + onUploadFailed: { showSelectMaterialPointAlert = true } + ) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + } + .animation(.easeInOut(duration: 0.24), value: viewModel.pageState) + } + .navigationTitle("宣传页设置") + .navigationBarTitleDisplayMode(.inline) + .appNavigationDestination(item: $viewModel.previewMaterial) { material in + ScenicPromotionMaterialPreviewView(material: material) + } + .safeAreaInset(edge: .bottom) { + if case .normal = viewModel.pageState { + ScenicPromotionSettingsSubmitBarView(onSubmit: handleSubmit) + } + } + .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() + } + } + + /// 提交设置并展示 Mock 结果提示。 + private func handleSubmit() { + if viewModel.submit() { + submitMessage = "宣传页设置已保存,当前为 Mock 演示数据。" + } else { + submitMessage = "请至少绑定一个排队打卡点。" + } + showSubmitAlert = true + } +} + +#if DEBUG +#Preview("宣传页设置") { + NavigationStack { + ScenicPromotionSettingsView() + } +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsViews.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsViews.swift deleted file mode 100644 index 2c0182f..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSettingsViews.swift +++ /dev/null @@ -1,598 +0,0 @@ -// -// 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( - 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) - } - } -} diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSpotTabsView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSpotTabsView.swift new file mode 100644 index 0000000..ea0d6b2 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionSpotTabsView.swift @@ -0,0 +1,101 @@ +// +// ScenicPromotionSpotTabsView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页打卡点标签区,5 个以内等分,超过 5 个时横向滚动。 +struct ScenicPromotionSpotTabsView: View { + let spots: [ScenicPromotionSpot] + let selectedSpotID: String? + let onSelectSpot: (ScenicPromotionSpot) -> Void + + var body: some View { + if spots.isEmpty { + EmptyView() + } else { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay { + 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 + ScenicPromotionSpotTabButton( + spot: spot, + isSelected: selectedSpotID == spot.id, + onTap: { onSelectSpot(spot) } + ) + .frame(width: tabWidth) + } + } + } + .scrollDisabled(spots.count <= Int(ScenicPromotionLayout.visibleSpotTabCount)) + } + } + .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) + } + } +} + +/// 单个打卡点标签按钮。 +struct ScenicPromotionSpotTabButton: View { + let spot: ScenicPromotionSpot + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + 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) + } +} + +#if DEBUG +#Preview("打卡点标签") { + ScenicPromotionSpotTabsView( + spots: ScenicPromotionPreviewSupport.sampleBoard.spots, + selectedSpotID: "bridge", + onSelectSpot: { _ in } + ) + .padding(.horizontal, 20) +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionView.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionView.swift new file mode 100644 index 0000000..3498231 --- /dev/null +++ b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionView.swift @@ -0,0 +1,145 @@ +// +// ScenicPromotionView.swift +// suixinkan +// +// Created by Codex on 2026/7/2. +// + +import SwiftUI + +/// 宣传页主视图,负责页面状态切换与隐藏设置入口。 +struct ScenicPromotionView: 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: + ScenicPromotionLoadingView() + .transition(.opacity.combined(with: .scale(scale: 0.98))) + case .empty: + ScenicPromotionFullStateView( + icon: "doc.text.image", + title: "暂无宣传页数据", + message: "当前景区还没有配置排队导览宣传内容", + buttonTitle: "重新加载" + ) { + Task { await viewModel.load() } + } + .transition(.opacity.combined(with: .move(edge: .bottom))) + case .error(let message): + ScenicPromotionFullStateView( + icon: "wifi.exclamationmark", + title: "加载失败", + message: message, + buttonTitle: "重试" + ) { + Task { await viewModel.load() } + } + .transition(.opacity.combined(with: .move(edge: .bottom))) + case .normal: + ScenicPromotionContentView( + viewModel: viewModel, + onQRCodeTap: handleQRCodeTap, + onQRCodeLongPress: { showDemoStateMenu = true }, + onVideoTap: { showPlayToast = true } + ) + .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() + } + } + + /// 二维码快速点击 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 + } + } +} + +#if DEBUG +#Preview("宣传页") { + NavigationStack { + ScenicPromotionView() + } +} +#endif diff --git a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionViews.swift b/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionViews.swift deleted file mode 100644 index f394746..0000000 --- a/suixinkan/Features/ScenicPromotion/Views/ScenicPromotionViews.swift +++ /dev/null @@ -1,789 +0,0 @@ -// -// 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() - } -} diff --git a/suixinkanTests/CooperationOrderAPITests.swift b/suixinkanTests/CooperationOrderAPITests.swift index 86d4f9c..5fa95b5 100644 --- a/suixinkanTests/CooperationOrderAPITests.swift +++ b/suixinkanTests/CooperationOrderAPITests.swift @@ -22,7 +22,8 @@ final class CooperationOrderAPITests: XCTestCase { XCTAssertEqual(response.page, 1) XCTAssertEqual(response.pageSize, 50) XCTAssertEqual(response.list.first?.displayId, 101) - XCTAssertEqual(response.list.first?.displayName, "管铜彬") + XCTAssertEqual(response.list.first?.displayName, "张哥") + XCTAssertEqual(response.list.first?.photogRemarkName, "张哥") XCTAssertEqual(response.list.first?.displayPhone, "19281871413") XCTAssertEqual(response.list.first?.bindingId, 5) XCTAssertEqual(response.list.first?.shareCode, "INV-000101") @@ -162,6 +163,23 @@ final class CooperationOrderAPITests: XCTestCase { let statusJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: statusBody) as? [String: Any]) XCTAssertEqual(statusJSON["status"] as? Int, 3) } + + /// 测试更新合作获客员备注名请求体。 + func testSaleUserUpdateRemarkUsesPostBody() async throws { + let emptyData = try TestFixture.data(named: "empty_success") + let session = CooperationRecordingURLSession(data: emptyData) + let api = CooperationOrderAPI(client: APIClient(session: session)) + + try await api.saleUserUpdateRemark(saleUserId: 123, photogRemarkName: "张哥") + + let request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/update-remark") + let body = try XCTUnwrap(request.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["sale_user_id"] as? Int, 123) + XCTAssertEqual(json["photog_remark_name"] as? String, "张哥") + } } /// URLSession 测试替身,记录合作订单 API 请求。 diff --git a/suixinkanTests/Fixtures/cooperative_salers_success.json b/suixinkanTests/Fixtures/cooperative_salers_success.json index bef9060..b95a59f 100644 --- a/suixinkanTests/Fixtures/cooperative_salers_success.json +++ b/suixinkanTests/Fixtures/cooperative_salers_success.json @@ -11,6 +11,7 @@ "binding_id": 5, "bound_at": "2026-06-25 17:11:01", "name": "管铜彬", + "photog_remark_name": "张哥", "phone": "19281871413", "phone_masked": "192****1413", "sale_user_id": 101,