拆分宣传页视图并扩充 Mock 数据,合作订单支持获客员备注名并修复线索图片网格尺寸。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-02 17:43:04 +08:00
parent 1b1e93b9ca
commit 328c4321f4
41 changed files with 2529 additions and 1436 deletions

View File

@ -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
)
)
)
}
}

View File

@ -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 |

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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(

View File

@ -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`

View File

@ -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
)
]
)
]
)

View File

@ -140,6 +140,8 @@ final class ScenicPromotionSettingsViewModel: ObservableObject {
@Published var selectedQueuePointIDs: Set<String> = []
@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 }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,234 @@
//
// ScenicPromotionSettingsSharedViews.swift
// suixinkan
//
// Created by Codex on 2026/7/2.
//
import SwiftUI
///
struct ScenicPromotionSettingsCard<Content: View>: 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

View File

@ -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

View File

@ -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

View File

@ -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<Content: View>(
title: String,
subtitle: String,
icon: String,
tint: Color,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(tint)
.frame(width: 40, height: 40)
.background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.system(size: 18, weight: .heavy))
.foregroundStyle(AppDesign.textPrimary)
Text(subtitle)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
content()
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(AppDesign.border, lineWidth: 1)
)
}
//
private func selectionField(title: String, subtitle: String, icon: String) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(2)
.multilineTextAlignment(.leading)
.minimumScaleFactor(0.82)
Text(subtitle)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer(minLength: 0)
Image(systemName: icon)
.font(.system(size: 13, weight: .bold))
.foregroundStyle(AppDesign.primary)
.frame(width: 30, height: 30)
.background(AppDesign.primarySoft, in: Circle())
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color(hex: 0xCFE1FF), lineWidth: 1)
)
}
//
private func inlineNotice(_ text: String, tint: Color) -> some View {
Label(text, systemImage: "exclamationmark.circle.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(tint)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
}
// /
private func materialEmptyView(_ kind: ScenicPromotionUploadKind) -> some View {
VStack(spacing: 8) {
Image(systemName: kind.iconName)
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 58, height: 58)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 16))
Text(kind.emptyTitle)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
Text("点击上传后会把素材保存到当前选择的打卡点")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
.background(Color(hex: 0xF8FAFD), in: RoundedRectangle(cornerRadius: 14))
}
//
private func materialRow(_ material: ScenicPromotionManagedMaterial) -> some View {
HStack(spacing: 10) {
Button {
previewMaterial = material
} label: {
HStack(spacing: 12) {
materialThumbnail(material)
VStack(alignment: .leading, spacing: 5) {
Text(material.title)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
Text(material.subtitle)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(1)
Text(material.createdAt)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(hex: 0x8A95A6))
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(Color(hex: 0x9AA5B5))
}
}
.buttonStyle(.plain)
Button {
viewModel.deleteMaterial(material)
} label: {
Image(systemName: "trash")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(AppDesign.danger)
.frame(width: 36, height: 36)
.background(Color(hex: 0xFFF1F1), in: Circle())
}
.buttonStyle(.plain)
}
.padding(10)
.background(Color(hex: 0xF8FAFD), in: RoundedRectangle(cornerRadius: 14))
}
//
private func materialThumbnail(_ material: ScenicPromotionManagedMaterial) -> some View {
ZStack {
Image(material.assetName)
.resizable()
.scaledToFill()
.frame(width: 64, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 10))
if material.kind == .video {
Image(systemName: "play.fill")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white)
.frame(width: 26, height: 26)
.background(.black.opacity(0.42), in: Circle())
}
}
}
// Loading
private var settingsLoadingView: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 16) {
RoundedRectangle(cornerRadius: 18)
.fill(.white)
.frame(height: 92)
ForEach(0..<4, id: \.self) { _ in
RoundedRectangle(cornerRadius: 18)
.fill(.white)
.frame(height: 132)
}
}
.redacted(reason: .placeholder)
.padding(16)
}
}
// Empty / Error
private func settingsFullStateView(
icon: String,
title: String,
message: String,
buttonTitle: String,
action: @escaping () -> Void
) -> some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: icon)
.font(.system(size: 42, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 82, height: 82)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 22))
VStack(spacing: 7) {
Text(title)
.font(.system(size: 20, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
Text(message)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
.multilineTextAlignment(.center)
}
Button(action: action) {
Text(buttonTitle)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(.white)
.frame(width: 132, height: 42)
.background(AppDesign.primary, in: Capsule())
}
.buttonStyle(.plain)
Spacer()
}
.padding(28)
}
//
private var submitBar: some View {
VStack(spacing: 0) {
Divider()
Button {
handleSubmit()
} label: {
Text("提交设置")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 10)
}
.background(.ultraThinMaterial)
}
}
// MARK: -
struct ScenicPromotionMaterialPreviewView: View {
// Mock
let material: ScenicPromotionManagedMaterial
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ZStack {
Image(material.assetName)
.resizable()
.scaledToFill()
.frame(height: 280)
.frame(maxWidth: .infinity)
.clipped()
if material.kind == .video {
Image(systemName: "play.fill")
.font(.system(size: 34, weight: .bold))
.foregroundStyle(.white)
.frame(width: 76, height: 76)
.background(.black.opacity(0.36), in: Circle())
.overlay(Circle().stroke(.white, lineWidth: 3))
}
}
.clipShape(RoundedRectangle(cornerRadius: 18))
VStack(alignment: .leading, spacing: 10) {
Text(material.title)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
Text(material.subtitle)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
Divider()
previewInfoRow(title: "素材类型", value: material.kind.shortTitle)
previewInfoRow(title: "上传时间", value: material.createdAt)
if let duration = material.duration {
previewInfoRow(title: "视频时长", value: duration)
}
}
.padding(16)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
}
.padding(16)
}
.background(AppDesign.background.ignoresSafeArea())
.navigationTitle(material.kind == .video ? "视频预览" : "图片预览")
.navigationBarTitleDisplayMode(.inline)
}
//
private func previewInfoRow(title: String, value: String) -> some View {
HStack {
Text(title)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Text(value)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
}
}
}

View File

@ -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

View File

@ -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<Void, Never>?
@State private var showPasswordPrompt = false
@State private var settingsPassword = ""
@State private var showPasswordError = false
@State private var showDemoStateMenu = false
@State private var showPlayToast = false
var body: some View {
ZStack {
ScenicPromotionSkyBackground()
.ignoresSafeArea()
Group {
switch viewModel.pageState {
case .idle, .loading:
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

View File

@ -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 {
// ViewModelView
@StateObject private var viewModel = ScenicPromotionViewModel()
// 使
@State private var settingsRoute: ScenicPromotionSettingsRoute?
//
@State private var qrTapCount = 0
@State private var qrTapResetTask: Task<Void, Never>?
//
@State private var showPasswordPrompt = false
@State private var settingsPassword = ""
@State private var showPasswordError = false
//
@State private var showDemoStateMenu = false
//
@State private var showPlayToast = false
var body: some View {
ZStack {
ScenicPromotionSkyBackground()
.ignoresSafeArea()
Group {
switch viewModel.pageState {
case .idle, .loading:
loadingView
.transition(.opacity.combined(with: .scale(scale: 0.98)))
case .empty:
fullStateView(
icon: "doc.text.image",
title: "暂无宣传页数据",
message: "当前景区还没有配置排队导览宣传内容",
buttonTitle: "重新加载"
) {
Task { await viewModel.load() }
}
.transition(.opacity.combined(with: .move(edge: .bottom)))
case .error(let message):
fullStateView(
icon: "wifi.exclamationmark",
title: "加载失败",
message: message,
buttonTitle: "重试"
) {
Task { await viewModel.load() }
}
.transition(.opacity.combined(with: .move(edge: .bottom)))
case .normal:
contentView
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.animation(.easeInOut(duration: 0.28), value: viewModel.pageState)
}
.ignoresSafeArea(.container, edges: .top)
.toolbar(.hidden, for: .navigationBar)
.appNavigationDestination(item: $settingsRoute) { _ in
ScenicPromotionSettingsView()
}
.alert("输入管理密码", isPresented: $showPasswordPrompt) {
SecureField("请输入密码", text: $settingsPassword)
Button("取消", role: .cancel) {
settingsPassword = ""
}
Button("进入") {
verifySettingsPassword()
}
} message: {
Text("连续点击二维码 3 次后可进入宣传页设置")
}
.alert("密码错误", isPresented: $showPasswordError) {
Button("知道了", role: .cancel) {}
} message: {
Text("请输入正确密码")
}
.alert("视频播放", isPresented: $showPlayToast) {
Button("知道了", role: .cancel) {}
} message: {
Text("Demo 已模拟播放入口,后续接入真实视频地址即可播放。")
}
.confirmationDialog("演示状态", isPresented: $showDemoStateMenu, titleVisibility: .visible) {
ForEach(ScenicPromotionDemoScenario.allCases) { scenario in
Button(scenario.title) {
viewModel.applyScenario(scenario)
}
}
Button("取消", role: .cancel) {}
}
.onDisappear {
qrTapResetTask?.cancel()
}
.task {
await viewModel.loadIfNeeded()
}
}
//
private var contentView: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: ScenicPromotionLayout.moduleVerticalSpacing) {
if let board = viewModel.board {
header(board)
spotTabs(board.spots)
metricsGrid
queuePanel
mediaToolbar
mediaStrip
heroMedia
}
}
.padding(.horizontal, ScenicPromotionLayout.screenEdgePadding)
.padding(.top, 28)
.padding(.bottom, 24)
}
}
//
private func header(_ board: ScenicPromotionBoard) -> some View {
ZStack(alignment: .topTrailing) {
VStack(spacing: 7) {
Text("\(board.scenicName) · 排队导览")
.font(.system(size: 26, weight: .heavy))
.foregroundStyle(Color(hex: 0x0E3F91))
.multilineTextAlignment(.center)
.minimumScaleFactor(0.72)
.lineLimit(1)
.shadow(color: .white.opacity(0.55), radius: 0, x: 0, y: 2)
HStack(spacing: 12) {
Rectangle()
.fill(Color(hex: 0x2F80ED, alpha: 0.35))
.frame(width: 36, height: 1)
Text(board.subtitle)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Color(hex: 0x1D5DA8))
Rectangle()
.fill(Color(hex: 0x2F80ED, alpha: 0.35))
.frame(width: 36, height: 1)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 8)
.padding(.horizontal, 62)
Button {
handleQRCodeTap()
} label: {
ScenicPromotionQRCodeView()
.frame(width: 56, height: 56)
.padding(5)
.background(.white, in: RoundedRectangle(cornerRadius: 9))
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.16), radius: 10, y: 4)
}
.buttonStyle(.plain)
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.7)
.onEnded { _ in
showDemoStateMenu = true
}
)
.accessibilityLabel("二维码设置入口")
}
.frame(height: 96)
}
// 3
private func handleQRCodeTap() {
qrTapCount += 1
qrTapResetTask?.cancel()
if qrTapCount >= 3 {
qrTapCount = 0
settingsPassword = ""
showPasswordPrompt = true
return
}
qrTapResetTask = Task {
try? await Task.sleep(nanoseconds: 1_100_000_000)
guard !Task.isCancelled else { return }
await MainActor.run {
qrTapCount = 0
}
}
}
// Mock 1
private func verifySettingsPassword() {
if settingsPassword == "1" {
settingsPassword = ""
settingsRoute = ScenicPromotionSettingsRoute()
} else {
settingsPassword = ""
showPasswordError = true
}
}
// 5 5 5
@ViewBuilder
private func spotTabs(_ spots: [ScenicPromotionSpot]) -> some View {
if spots.isEmpty {
EmptyView()
} else {
GeometryReader { proxy in
let visibleCount = min(CGFloat(max(spots.count, 1)), ScenicPromotionLayout.visibleSpotTabCount)
let tabWidth = proxy.size.width / visibleCount
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(spots) { spot in
spotTabButton(spot)
.frame(width: tabWidth)
}
}
}
.scrollDisabled(spots.count <= Int(ScenicPromotionLayout.visibleSpotTabCount))
}
.frame(height: 42)
.background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14))
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(.white.opacity(0.8), lineWidth: 1)
)
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.12), radius: 12, y: 5)
}
}
// 5
private func spotTabButton(_ spot: ScenicPromotionSpot) -> some View {
let isSelected = viewModel.selectedSpot?.id == spot.id
return Button {
viewModel.selectSpot(spot)
} label: {
HStack(spacing: 3) {
Image(systemName: spot.iconName)
.font(.system(size: 13, weight: .bold))
Text(spot.name)
.font(.system(size: 12, weight: .bold))
.lineLimit(1)
.minimumScaleFactor(0.66)
}
.foregroundStyle(isSelected ? .white : Color(hex: 0x14213D))
.frame(maxWidth: .infinity)
.frame(height: 42)
.padding(.horizontal, 4)
.background(
Group {
if isSelected {
LinearGradient(
colors: [Color(hex: 0x1286FF), Color(hex: 0x006BFF)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
Color.clear
}
}
)
}
.buttonStyle(.plain)
}
//
private var metricsGrid: some View {
let queue = viewModel.queue
let metrics: [(String, String, String, Color)] = [
("当前排队人数", queue.map { "\($0.waitingPeople)" } ?? "--", "", Color(hex: 0x1580FF)),
("预计等待时间", queue.map { "\($0.estimatedMinutes)" } ?? "--", "分钟", Color(hex: 0xF28C18)),
("平均等待时长", queue.map { "\($0.averageMinutes)" } ?? "--", "分钟", Color(hex: 0x2D8E43))
]
return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) {
ForEach(metrics, id: \.0) { metric in
VStack(spacing: 8) {
Text(metric.0)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color(hex: 0x113B79))
.lineLimit(1)
.minimumScaleFactor(0.76)
HStack(alignment: .lastTextBaseline, spacing: 2) {
Text(metric.1)
.font(.system(size: 34, weight: .heavy))
.foregroundStyle(metric.3)
.minimumScaleFactor(0.55)
if metric.1 != "--" {
Text(metric.2)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(metric.3)
}
}
if metric.1 == "--" {
Text("暂无数据")
.font(.system(size: 11))
.foregroundStyle(Color(hex: 0x5272A5))
}
}
.padding(.horizontal, 6)
.frame(maxWidth: .infinity)
.frame(height: 92)
.background(.white.opacity(0.84), in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(.white.opacity(0.85), lineWidth: 1)
)
}
}
}
//
private var queuePanel: some View {
Group {
if let queue = viewModel.queue {
HStack(spacing: 10) {
queueSide(icon: "camera.aperture", title: "下一位", value: queue.nextNumber, tint: Color(hex: 0x197BFF))
Divider()
.frame(height: 52)
VStack(spacing: 0) {
Text("当前叫号")
.font(.system(size: 15, weight: .bold))
.foregroundStyle(Color(hex: 0x123F86))
Text(queue.currentNumber)
.font(.system(size: 38, weight: .heavy))
.foregroundStyle(Color(hex: 0x197BFF))
.minimumScaleFactor(0.55)
}
.frame(maxWidth: .infinity)
Divider()
.frame(height: 52)
queueSide(icon: "person.2.fill", title: "拍摄用时", value: queue.shootingDuration, tint: Color(hex: 0x7A4DFF))
}
.padding(.horizontal, 14)
.padding(.vertical, 7)
} else {
HStack(spacing: 18) {
Image(systemName: "list.clipboard")
.font(.system(size: 46, weight: .semibold))
.foregroundStyle(Color(hex: 0x2F80ED))
.frame(width: 92, height: 92)
.background(Color(hex: 0xEAF4FF), in: RoundedRectangle(cornerRadius: 18))
VStack(alignment: .leading, spacing: 8) {
Text("当前暂无排队信息")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(Color(hex: 0x123F86))
Text("可先浏览景点样片,稍后再查看排队动态")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Color(hex: 0x5272A5))
}
Spacer(minLength: 0)
}
.padding(18)
}
}
.frame(maxWidth: .infinity)
.background(.white.opacity(0.86), in: RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(.white.opacity(0.9), lineWidth: 1)
)
.shadow(color: Color(hex: 0x0D47A1, alpha: 0.10), radius: 14, y: 6)
}
//
private func queueSide(icon: String, title: String, value: String, tint: Color) -> some View {
HStack(spacing: 7) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 34, height: 34)
.background(tint.opacity(0.13), in: Circle())
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(hex: 0x113B79))
Text(value)
.font(.system(size: 13, weight: .heavy))
.foregroundStyle(tint)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.minimumScaleFactor(0.76)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
// /
private var mediaToolbar: some View {
HStack(spacing: 14) {
HStack(spacing: 0) {
ForEach(ScenicPromotionMediaMode.allCases) { mode in
Button {
viewModel.selectMode(mode)
} label: {
HStack(spacing: 6) {
Image(systemName: mode.iconName)
Text(mode.title)
}
.font(.system(size: 14, weight: .bold))
.foregroundStyle(viewModel.selectedMode == mode ? .white : Color(hex: 0x1B4D91))
.frame(width: 68, height: 34)
.background(
viewModel.selectedMode == mode
? Color(hex: 0x0B79FF)
: Color.clear
)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(.white.opacity(0.78), in: Capsule())
.overlay(Capsule().stroke(.white.opacity(0.9), lineWidth: 1))
Spacer()
Label("触屏切换视频/照片,左右滑动查看更多", systemImage: "hand.tap.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color(hex: 0x124E97))
.lineLimit(1)
.minimumScaleFactor(0.58)
}
}
//
private var mediaStrip: some View {
HStack(spacing: 8) {
Button {
viewModel.moveMedia(-1)
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(Color(hex: 0x326EAE))
.frame(width: 28, height: 100)
}
.buttonStyle(.plain)
if viewModel.mediaItems.isEmpty {
mediaEmptyPanel
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(viewModel.mediaItems) { media in
Button {
viewModel.selectMedia(media)
} label: {
ScenicPromotionMediaArtwork(media: media, compact: true, useHeroAsset: false)
.frame(width: media.previewWidth, height: 66)
.overlay(alignment: .bottomTrailing) {
if let duration = media.duration {
Text(duration)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.black.opacity(0.42), in: Capsule())
.padding(6)
}
}
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(
viewModel.selectedMedia?.id == media.id ? Color(hex: 0x0B79FF) : .white.opacity(0.75),
lineWidth: viewModel.selectedMedia?.id == media.id ? 3 : 1
)
)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 2)
}
}
Button {
viewModel.moveMedia(1)
} label: {
Image(systemName: "chevron.right")
.font(.system(size: 22, weight: .heavy))
.foregroundStyle(Color(hex: 0x326EAE))
.frame(width: 28, height: 100)
}
.buttonStyle(.plain)
}
.padding(10)
.background(.white.opacity(0.72), in: RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(.white.opacity(0.85), lineWidth: 1)
)
}
// /
private var mediaEmptyPanel: some View {
VStack(spacing: 12) {
Image(systemName: viewModel.selectedMode == .video ? "play.rectangle.fill" : "photo.on.rectangle.angled")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(Color(hex: 0x57A2FF))
Text(viewModel.selectedMode == .video ? "暂无视频" : "暂无照片")
.font(.system(size: 19, weight: .bold))
.foregroundStyle(Color(hex: 0x1E5DA8))
Text("敬请期待更多精彩内容")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Color(hex: 0x6B83A8))
}
.frame(maxWidth: .infinity, minHeight: 172)
}
//
private var heroMedia: some View {
Group {
if let media = viewModel.selectedMedia {
Button {
if media.mode == .video {
showPlayToast = true
}
} label: {
ZStack(alignment: .bottomLeading) {
ScenicPromotionMediaArtwork(media: media, compact: false, useHeroAsset: true)
.frame(height: 220)
if media.heroAssetName == nil {
LinearGradient(
colors: [.clear, .black.opacity(0.56)],
startPoint: .center,
endPoint: .bottom
)
VStack(alignment: .leading, spacing: 8) {
if media.mode == .video {
Image(systemName: "play.fill")
.font(.system(size: 30, weight: .bold))
.foregroundStyle(.white)
.frame(width: 64, height: 64)
.background(.white.opacity(0.2), in: Circle())
.overlay(Circle().stroke(.white, lineWidth: 3))
.frame(maxWidth: .infinity, alignment: .center)
}
Label(media.title, systemImage: "mappin.circle.fill")
.font(.system(size: 20, weight: .heavy))
.foregroundStyle(.white)
Text(media.subtitle)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.92))
}
.padding(18)
}
}
.clipShape(RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(.white.opacity(0.86), lineWidth: 1)
)
}
.buttonStyle(.plain)
} else {
mediaEmptyPanel
.frame(height: 220)
.frame(maxWidth: .infinity)
.background(.white.opacity(0.7), in: RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(.white.opacity(0.85), lineWidth: 1)
)
}
}
}
// Loading
private var loadingView: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 18) {
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.72))
.frame(height: 110)
RoundedRectangle(cornerRadius: 16)
.fill(.white.opacity(0.72))
.frame(height: 50)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 3), spacing: 12) {
ForEach(0..<3, id: \.self) { _ in
RoundedRectangle(cornerRadius: 14)
.fill(.white.opacity(0.72))
.frame(height: 92)
}
}
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.72))
.frame(height: 96)
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.72))
.frame(height: 112)
RoundedRectangle(cornerRadius: 18)
.fill(.white.opacity(0.72))
.frame(height: 220)
}
.redacted(reason: .placeholder)
.padding(ScenicPromotionLayout.screenEdgePadding)
}
}
// Empty / Error
private func fullStateView(
icon: String,
title: String,
message: String,
buttonTitle: String,
action: @escaping () -> Void
) -> some View {
VStack(spacing: 18) {
Spacer()
Image(systemName: icon)
.font(.system(size: 48, weight: .semibold))
.foregroundStyle(Color(hex: 0x0B79FF))
.frame(width: 92, height: 92)
.background(.white.opacity(0.75), in: RoundedRectangle(cornerRadius: 24))
VStack(spacing: 8) {
Text(title)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(Color(hex: 0x143D78))
Text(message)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Color(hex: 0x5F789E))
.multilineTextAlignment(.center)
}
Button(action: action) {
Text(buttonTitle)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
.frame(width: 150, height: 46)
.background(Color(hex: 0x0B79FF), in: Capsule())
}
.buttonStyle(.plain)
Spacer()
}
.padding(28)
}
}
// MARK: -
private struct ScenicPromotionMediaArtwork: View {
let media: ScenicPromotionMedia
let compact: Bool
let useHeroAsset: Bool
var body: some View {
GeometryReader { proxy in
if let assetName = useHeroAsset ? (media.heroAssetName ?? media.assetName) : media.assetName {
Image(assetName)
.resizable()
.scaledToFill()
.frame(width: proxy.size.width, height: proxy.size.height)
.clipped()
} else {
ZStack {
LinearGradient(
colors: media.palette.map { Color(hex: $0) },
startPoint: .topLeading,
endPoint: .bottomTrailing
)
ScenicPromotionMountainLayer()
.fill(.white.opacity(0.28))
.frame(height: proxy.size.height * 0.45)
.offset(y: proxy.size.height * 0.02)
ScenicPromotionMountainLayer()
.fill(Color(hex: 0x0F5B85, alpha: 0.22))
.frame(height: proxy.size.height * 0.38)
.offset(y: proxy.size.height * 0.13)
VStack {
Spacer()
Rectangle()
.fill(Color(hex: 0x2D9D4E, alpha: 0.52))
.frame(height: proxy.size.height * 0.2)
}
if !compact {
Image(systemName: "figure.walk")
.font(.system(size: 74, weight: .bold))
.foregroundStyle(.white.opacity(0.82))
.offset(x: proxy.size.width * 0.23, y: proxy.size.height * 0.1)
}
}
}
}
.clipShape(RoundedRectangle(cornerRadius: compact ? 12 : 18))
}
}
// 使
private struct ScenicPromotionMountainLayer: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.width * 0.16, y: rect.height * 0.42))
path.addLine(to: CGPoint(x: rect.width * 0.28, y: rect.height * 0.68))
path.addLine(to: CGPoint(x: rect.width * 0.42, y: rect.height * 0.25))
path.addLine(to: CGPoint(x: rect.width * 0.58, y: rect.height * 0.6))
path.addLine(to: CGPoint(x: rect.width * 0.76, y: rect.height * 0.18))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.height * 0.56))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.closeSubpath()
return path
}
}
// 绿
private struct ScenicPromotionSkyBackground: View {
var body: some View {
ZStack(alignment: .bottom) {
Image("ScenicPromotionBackground")
.resizable()
.scaledToFill()
.overlay(
LinearGradient(
colors: [
Color(hex: 0x0E88FF, alpha: 0.12),
Color(hex: 0xE9F8FF, alpha: 0.5),
Color(hex: 0x82D860, alpha: 0.16)
],
startPoint: .top,
endPoint: .bottom
)
)
LinearGradient(
colors: [
Color(hex: 0x1593FF, alpha: 0.16),
Color(hex: 0xBFE9FF, alpha: 0.10),
Color(hex: 0xEAF8FF, alpha: 0.42)
],
startPoint: .top,
endPoint: .bottom
)
LinearGradient(
colors: [Color.clear, Color(hex: 0x62BA4C, alpha: 0.18)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 260)
}
}
}
//
private struct ScenicPromotionQRCodeView: View {
var body: some View {
Image("ScenicPromotionQRCode")
.resizable()
.scaledToFit()
}
}

View File

@ -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

View File

@ -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,