拆分宣传页视图并扩充 Mock 数据,合作订单支持获客员备注名并修复线索图片网格尺寸。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -33,6 +33,9 @@ protocol CooperationOrderServing {
|
|||||||
|
|
||||||
/// 更新带客单状态(如退款时 status=3)。
|
/// 更新带客单状态(如退款时 status=3)。
|
||||||
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws
|
func saleUserUpdateReferralOrderStatus(id: Int, status: Int) async throws
|
||||||
|
|
||||||
|
/// 更新合作获客员备注名。
|
||||||
|
func saleUserUpdateRemark(saleUserId: Int, photogRemarkName: String) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,13 @@
|
|||||||
2. 扫码或主 Tab 扫码识别 `{"method":"sale_user_bind","sale_user_id":N}`
|
2. 扫码或主 Tab 扫码识别 `{"method":"sale_user_bind","sale_user_id":N}`
|
||||||
3. `BindAcquirerView` 加载邀请信息 → 确认 → `POST .../sale-user/accept-bind`
|
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 线下扫码)在订单卡片展示「订单来源」:
|
订单管理(`orderType` 11 摄影师跟拍 / 14 线下扫码)在订单卡片展示「订单来源」:
|
||||||
@ -43,7 +50,7 @@
|
|||||||
|
|
||||||
| 文件 | 说明 |
|
| 文件 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `API/CooperationOrderAPI.swift` | 8 个 sale-user / cooperative 接口 |
|
| `API/CooperationOrderAPI.swift` | 9 个 sale-user / cooperative 接口 |
|
||||||
| `Models/CooperationOrderModels.swift` | 实体与解码 |
|
| `Models/CooperationOrderModels.swift` | 实体与解码 |
|
||||||
| `Services/CooperationOrderFeature.swift` | 权限 URI 常量 |
|
| `Services/CooperationOrderFeature.swift` | 权限 URI 常量 |
|
||||||
| `ViewModels/*` | 列表、获客员、绑定 ViewModel |
|
| `ViewModels/*` | 列表、获客员、绑定 ViewModel |
|
||||||
|
|||||||
@ -13,6 +13,11 @@ extension String {
|
|||||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return trimmed.isEmpty ? fallback : trimmed
|
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
|
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 {
|
struct CooperativeSalerListPayload: Decodable {
|
||||||
let total: Int
|
let total: Int
|
||||||
@ -49,6 +65,7 @@ struct CooperativeSaler: Decodable, Identifiable {
|
|||||||
let bindingId: Int
|
let bindingId: Int
|
||||||
let name: String
|
let name: String
|
||||||
let salerName: String
|
let salerName: String
|
||||||
|
let photogRemarkName: String
|
||||||
let phone: String
|
let phone: String
|
||||||
let phoneMasked: String
|
let phoneMasked: String
|
||||||
let salerPhone: String
|
let salerPhone: String
|
||||||
@ -62,7 +79,7 @@ struct CooperativeSaler: Decodable, Identifiable {
|
|||||||
var displayId: Int { saleUserId }
|
var displayId: Int { saleUserId }
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
name.cooperationNonEmptyOrDefault(salerName)
|
photogRemarkName.saleUserDisplayName(primaryName: name, fallbackName: salerName)
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayPhone: String {
|
var displayPhone: String {
|
||||||
@ -78,6 +95,7 @@ struct CooperativeSaler: Decodable, Identifiable {
|
|||||||
case bindingId = "binding_id"
|
case bindingId = "binding_id"
|
||||||
case name
|
case name
|
||||||
case salerName = "saler_name"
|
case salerName = "saler_name"
|
||||||
|
case photogRemarkName = "photog_remark_name"
|
||||||
case phone
|
case phone
|
||||||
case phoneMasked = "phone_masked"
|
case phoneMasked = "phone_masked"
|
||||||
case salerPhone = "saler_phone"
|
case salerPhone = "saler_phone"
|
||||||
@ -93,6 +111,7 @@ struct CooperativeSaler: Decodable, Identifiable {
|
|||||||
bindingId = try container.decodeLossyInt(forKey: .bindingId) ?? 0
|
bindingId = try container.decodeLossyInt(forKey: .bindingId) ?? 0
|
||||||
name = try container.decodeLossyString(forKey: .name)
|
name = try container.decodeLossyString(forKey: .name)
|
||||||
salerName = try container.decodeLossyString(forKey: .salerName)
|
salerName = try container.decodeLossyString(forKey: .salerName)
|
||||||
|
photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName)
|
||||||
phone = try container.decodeLossyString(forKey: .phone)
|
phone = try container.decodeLossyString(forKey: .phone)
|
||||||
phoneMasked = try container.decodeLossyString(forKey: .phoneMasked)
|
phoneMasked = try container.decodeLossyString(forKey: .phoneMasked)
|
||||||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||||||
@ -101,6 +120,52 @@ struct CooperativeSaler: Decodable, Identifiable {
|
|||||||
bindTime = try container.decodeLossyString(forKey: .bindTime)
|
bindTime = try container.decodeLossyString(forKey: .bindTime)
|
||||||
boundAt = try container.decodeLossyString(forKey: .boundAt)
|
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 orderStatusName: String
|
||||||
let status: String
|
let status: String
|
||||||
let saleUserName: String
|
let saleUserName: String
|
||||||
|
let photogRemarkName: String
|
||||||
let partnerName: String
|
let partnerName: String
|
||||||
let partnerRole: String
|
let partnerRole: String
|
||||||
let remark: String
|
let remark: String
|
||||||
@ -249,7 +315,8 @@ struct AcquisitionOrder: Decodable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var displayPartner: String {
|
var displayPartner: String {
|
||||||
if !saleUserName.isEmpty { return saleUserName }
|
let salerDisplayName = photogRemarkName.saleUserDisplayName(primaryName: saleUserName)
|
||||||
|
if !salerDisplayName.isEmpty { return salerDisplayName }
|
||||||
if !partnerName.isEmpty { return partnerName }
|
if !partnerName.isEmpty { return partnerName }
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@ -270,6 +337,7 @@ struct AcquisitionOrder: Decodable, Identifiable {
|
|||||||
case orderStatusName = "order_status_name"
|
case orderStatusName = "order_status_name"
|
||||||
case status
|
case status
|
||||||
case saleUserName = "sale_user_name"
|
case saleUserName = "sale_user_name"
|
||||||
|
case photogRemarkName = "photog_remark_name"
|
||||||
case partnerName = "partner_name"
|
case partnerName = "partner_name"
|
||||||
case partnerRole = "partner_role"
|
case partnerRole = "partner_role"
|
||||||
case remark
|
case remark
|
||||||
@ -293,6 +361,7 @@ struct AcquisitionOrder: Decodable, Identifiable {
|
|||||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||||
status = try container.decodeLossyString(forKey: .status)
|
status = try container.decodeLossyString(forKey: .status)
|
||||||
saleUserName = try container.decodeLossyString(forKey: .saleUserName)
|
saleUserName = try container.decodeLossyString(forKey: .saleUserName)
|
||||||
|
photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName)
|
||||||
partnerName = try container.decodeLossyString(forKey: .partnerName)
|
partnerName = try container.decodeLossyString(forKey: .partnerName)
|
||||||
partnerRole = try container.decodeLossyString(forKey: .partnerRole)
|
partnerRole = try container.decodeLossyString(forKey: .partnerRole)
|
||||||
remark = try container.decodeLossyString(forKey: .remark)
|
remark = try container.decodeLossyString(forKey: .remark)
|
||||||
@ -304,11 +373,17 @@ struct AcquisitionOrder: Decodable, Identifiable {
|
|||||||
struct ReferralOrderSaler: Decodable {
|
struct ReferralOrderSaler: Decodable {
|
||||||
let saleUserId: Int
|
let saleUserId: Int
|
||||||
let salerName: String
|
let salerName: String
|
||||||
|
let photogRemarkName: String
|
||||||
let salerPhone: String
|
let salerPhone: String
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
photogRemarkName.saleUserDisplayName(primaryName: salerName)
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case saleUserId = "sale_user_id"
|
case saleUserId = "sale_user_id"
|
||||||
case salerName = "saler_name"
|
case salerName = "saler_name"
|
||||||
|
case photogRemarkName = "photog_remark_name"
|
||||||
case salerPhone = "saler_phone"
|
case salerPhone = "saler_phone"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,6 +391,7 @@ struct ReferralOrderSaler: Decodable {
|
|||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||||||
salerName = try container.decodeLossyString(forKey: .salerName)
|
salerName = try container.decodeLossyString(forKey: .salerName)
|
||||||
|
photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName)
|
||||||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -394,6 +470,7 @@ struct BoundReferralOrder: Decodable, Equatable, Hashable {
|
|||||||
let orderNo: String
|
let orderNo: String
|
||||||
let saleUserId: Int
|
let saleUserId: Int
|
||||||
let salerName: String
|
let salerName: String
|
||||||
|
let photogRemarkName: String
|
||||||
let salerPhone: String
|
let salerPhone: String
|
||||||
let displayText: String
|
let displayText: String
|
||||||
let userMobile: String
|
let userMobile: String
|
||||||
@ -405,11 +482,16 @@ struct BoundReferralOrder: Decodable, Equatable, Hashable {
|
|||||||
let statusLabel: String
|
let statusLabel: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
|
|
||||||
|
var displaySalerName: String {
|
||||||
|
photogRemarkName.saleUserDisplayName(primaryName: salerName)
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
case orderNo = "order_no"
|
case orderNo = "order_no"
|
||||||
case saleUserId = "sale_user_id"
|
case saleUserId = "sale_user_id"
|
||||||
case salerName = "saler_name"
|
case salerName = "saler_name"
|
||||||
|
case photogRemarkName = "photog_remark_name"
|
||||||
case salerPhone = "saler_phone"
|
case salerPhone = "saler_phone"
|
||||||
case displayText = "display_text"
|
case displayText = "display_text"
|
||||||
case userMobile = "user_mobile"
|
case userMobile = "user_mobile"
|
||||||
@ -428,6 +510,7 @@ struct BoundReferralOrder: Decodable, Equatable, Hashable {
|
|||||||
orderNo = try container.decodeLossyString(forKey: .orderNo)
|
orderNo = try container.decodeLossyString(forKey: .orderNo)
|
||||||
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
saleUserId = try container.decodeLossyInt(forKey: .saleUserId) ?? 0
|
||||||
salerName = try container.decodeLossyString(forKey: .salerName)
|
salerName = try container.decodeLossyString(forKey: .salerName)
|
||||||
|
photogRemarkName = try container.decodeLossyString(forKey: .photogRemarkName)
|
||||||
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
salerPhone = try container.decodeLossyString(forKey: .salerPhone)
|
||||||
displayText = try container.decodeLossyString(forKey: .displayText)
|
displayText = try container.decodeLossyString(forKey: .displayText)
|
||||||
userMobile = try container.decodeLossyString(forKey: .userMobile)
|
userMobile = try container.decodeLossyString(forKey: .userMobile)
|
||||||
|
|||||||
@ -27,4 +27,19 @@ final class CooperationAcquirerViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
hasLoadedOnce = true
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,9 @@ struct CooperationAcquirerView: View {
|
|||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@StateObject private var viewModel = CooperationAcquirerViewModel()
|
@StateObject private var viewModel = CooperationAcquirerViewModel()
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
|
@State private var showRemarkAlert = false
|
||||||
|
@State private var remarkEditingAcquirer: CooperativeSaler?
|
||||||
|
@State private var remarkDraft = ""
|
||||||
let onBind: (Int) -> Void
|
let onBind: (Int) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -31,6 +34,10 @@ struct CooperationAcquirerView: View {
|
|||||||
ForEach(viewModel.acquirers, id: \.displayId) { acquirer in
|
ForEach(viewModel.acquirers, id: \.displayId) { acquirer in
|
||||||
CooperativeSalerCard(acquirer: acquirer) { phone in
|
CooperativeSalerCard(acquirer: acquirer) { phone in
|
||||||
dialPhone(phone)
|
dialPhone(phone)
|
||||||
|
} onEditRemark: {
|
||||||
|
remarkEditingAcquirer = acquirer
|
||||||
|
remarkDraft = acquirer.photogRemarkName
|
||||||
|
showRemarkAlert = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,6 +80,44 @@ struct CooperationAcquirerView: View {
|
|||||||
.task {
|
.task {
|
||||||
await reload(showLoading: true)
|
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 {
|
private func reload(showLoading: Bool) async {
|
||||||
|
|||||||
@ -218,25 +218,36 @@ struct ReferralLeadImageGrid: View {
|
|||||||
} else {
|
} else {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(0..<3, id: \.self) { index in
|
ForEach(0..<3, id: \.self) { index in
|
||||||
|
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]) {
|
if index < images.count, let url = URL(string: images[index]) {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(CooperationOrderColors.imagePlaceholderBg)
|
||||||
|
.overlay {
|
||||||
KFImage(url)
|
KFImage(url)
|
||||||
.placeholder { CooperationOrderColors.imagePlaceholderBg }
|
.placeholder { CooperationOrderColors.imagePlaceholderBg }
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.aspectRatio(1, contentMode: .fit)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
.onTapGesture {
|
|
||||||
onImageTap(images, index)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(maxWidth: .infinity)
|
}
|
||||||
|
}
|
||||||
.aspectRatio(1, contentMode: .fit)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
}
|
.frame(maxWidth: .infinity)
|
||||||
}
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
}
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
guard index < images.count else { return }
|
||||||
|
onImageTap(images, index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -290,8 +301,10 @@ struct AcquisitionOrderCard: View {
|
|||||||
struct CooperativeSalerCard: View {
|
struct CooperativeSalerCard: View {
|
||||||
let acquirer: CooperativeSaler
|
let acquirer: CooperativeSaler
|
||||||
let onDial: (String) -> Void
|
let onDial: (String) -> Void
|
||||||
|
let onEditRemark: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
HStack(alignment: .center, spacing: 0) {
|
HStack(alignment: .center, spacing: 0) {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
.font(.system(size: 20))
|
.font(.system(size: 20))
|
||||||
@ -319,6 +332,15 @@ struct CooperativeSalerCard: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(.white, in: RoundedRectangle(cornerRadius: 10))
|
.background(.white, in: RoundedRectangle(cornerRadius: 10))
|
||||||
.shadow(color: Color.black.opacity(0.06), radius: 1, x: 0, y: 1)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -89,8 +89,14 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
let showOrderSource: Int
|
let showOrderSource: Int
|
||||||
let referralOrderNo: String
|
let referralOrderNo: String
|
||||||
let referralSalerName: String
|
let referralSalerName: String
|
||||||
|
let referralSalerPhotogRemarkName: String
|
||||||
let referralOrder: BoundReferralOrder?
|
let referralOrder: BoundReferralOrder?
|
||||||
|
|
||||||
|
/// 订单卡片展示的获客员名称,优先备注名。
|
||||||
|
var displayReferralSalerName: String {
|
||||||
|
referralSalerPhotogRemarkName.saleUserDisplayName(primaryName: referralSalerName)
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case photogUid = "photog_uid"
|
case photogUid = "photog_uid"
|
||||||
case orderNumber = "order_number"
|
case orderNumber = "order_number"
|
||||||
@ -123,6 +129,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
case showOrderSource = "show_order_source"
|
case showOrderSource = "show_order_source"
|
||||||
case referralOrderNo = "referral_order_no"
|
case referralOrderNo = "referral_order_no"
|
||||||
case referralSalerName = "referral_saler_name"
|
case referralSalerName = "referral_saler_name"
|
||||||
|
case referralSalerPhotogRemarkName = "photog_remark_name"
|
||||||
case referralOrder = "referral_order"
|
case referralOrder = "referral_order"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,6 +167,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
|
|||||||
showOrderSource = try container.decodeLossyInt(forKey: .showOrderSource) ?? 0
|
showOrderSource = try container.decodeLossyInt(forKey: .showOrderSource) ?? 0
|
||||||
referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo)
|
referralOrderNo = try container.decodeLossyString(forKey: .referralOrderNo)
|
||||||
referralSalerName = try container.decodeLossyString(forKey: .referralSalerName)
|
referralSalerName = try container.decodeLossyString(forKey: .referralSalerName)
|
||||||
|
referralSalerPhotogRemarkName = try container.decodeLossyString(forKey: .referralSalerPhotogRemarkName)
|
||||||
referralOrder = try container.decodeIfPresent(BoundReferralOrder.self, forKey: .referralOrder)
|
referralOrder = try container.decodeIfPresent(BoundReferralOrder.self, forKey: .referralOrder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,12 +52,13 @@ extension ReferralOrder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension BoundReferralOrder {
|
extension BoundReferralOrder {
|
||||||
func toOrderSourceSelection(fallbackSalerName: String = "") -> OrderSourceSelection? {
|
func toOrderSourceSelection(fallbackSalerName: String = "", fallbackPhotogRemarkName: String = "") -> OrderSourceSelection? {
|
||||||
let referralNo = orderNo.trimmingCharacters(in: .whitespacesAndNewlines)
|
let referralNo = orderNo.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !referralNo.isEmpty else { return nil }
|
guard !referralNo.isEmpty else { return nil }
|
||||||
let personName = salerName.isEmpty
|
let fallbackDisplayName = fallbackPhotogRemarkName.saleUserDisplayName(primaryName: fallbackSalerName)
|
||||||
? (fallbackSalerName.isEmpty ? "—" : fallbackSalerName)
|
let personName = displaySalerName.isEmpty
|
||||||
: salerName
|
? (fallbackDisplayName.isEmpty ? "—" : fallbackDisplayName)
|
||||||
|
: displaySalerName
|
||||||
let remarkText: String
|
let remarkText: String
|
||||||
if !remark.isEmpty {
|
if !remark.isEmpty {
|
||||||
remarkText = remark
|
remarkText = remark
|
||||||
@ -85,14 +86,18 @@ extension BoundReferralOrder {
|
|||||||
extension OrderEntity {
|
extension OrderEntity {
|
||||||
func toBoundOrderSourceSelection() -> OrderSourceSelection? {
|
func toBoundOrderSourceSelection() -> OrderSourceSelection? {
|
||||||
if let referralOrder,
|
if let referralOrder,
|
||||||
let selection = referralOrder.toOrderSourceSelection(fallbackSalerName: referralSalerName) {
|
let selection = referralOrder.toOrderSourceSelection(
|
||||||
|
fallbackSalerName: referralSalerName,
|
||||||
|
fallbackPhotogRemarkName: referralSalerPhotogRemarkName
|
||||||
|
) {
|
||||||
return selection
|
return selection
|
||||||
}
|
}
|
||||||
guard !referralOrderNo.isEmpty else { return nil }
|
guard !referralOrderNo.isEmpty else { return nil }
|
||||||
|
let personName = displayReferralSalerName.isEmpty ? "—" : displayReferralSalerName
|
||||||
return OrderSourceSelection(
|
return OrderSourceSelection(
|
||||||
person: OrderSourcePerson(
|
person: OrderSourcePerson(
|
||||||
key: "",
|
key: "",
|
||||||
name: referralSalerName.isEmpty ? "—" : referralSalerName,
|
name: personName,
|
||||||
phone: ""
|
phone: ""
|
||||||
),
|
),
|
||||||
lead: OrderSourceLead(
|
lead: OrderSourceLead(
|
||||||
|
|||||||
@ -37,7 +37,7 @@ flowchart TD
|
|||||||
|
|
||||||
## 主页面规则
|
## 主页面规则
|
||||||
|
|
||||||
1. 打卡点由接口返回列表动态展示;Mock 含网红桥、汉地拔冲、空中草原、河谷花海、雪山栈道。
|
1. 打卡点由接口返回列表动态展示;Mock 含网红桥、汉地拔冲、空中草原、河谷花海、雪山栈道、猎鹰台、雪莲谷、游牧人家。
|
||||||
2. 5 个以内等分展示,超过 5 个时一屏露出 5 个并横向滚动。
|
2. 5 个以内等分展示,超过 5 个时一屏露出 5 个并横向滚动。
|
||||||
3. 缩略图点击只联动底部主视觉,不跳转二级页。
|
3. 缩略图点击只联动底部主视觉,不跳转二级页。
|
||||||
4. 视频主视觉点击仅展示模拟播放提示。
|
4. 视频主视觉点击仅展示模拟播放提示。
|
||||||
@ -75,5 +75,8 @@ flowchart TD
|
|||||||
- `Models/ScenicPromotionModels.swift` — 状态枚举与数据实体
|
- `Models/ScenicPromotionModels.swift` — 状态枚举与数据实体
|
||||||
- `Services/ScenicPromotionMockStore.swift` — Mock 种子数据
|
- `Services/ScenicPromotionMockStore.swift` — Mock 种子数据
|
||||||
- `ViewModels/ScenicPromotionViewModels.swift` — 主页面与设置页 ViewModel
|
- `ViewModels/ScenicPromotionViewModels.swift` — 主页面与设置页 ViewModel
|
||||||
- `Views/ScenicPromotionViews.swift` — 主页面
|
- `Views/ScenicPromotionView.swift` — 主页面入口
|
||||||
- `Views/ScenicPromotionSettingsViews.swift` — 设置页与素材预览
|
- `Views/ScenicPromotionContentView.swift` — 主页面内容组装
|
||||||
|
- `Views/ScenicPromotionSettingsView.swift` — 设置页入口
|
||||||
|
- `Views/ScenicPromotionPreviewSupport.swift` — Preview 工厂(DEBUG)
|
||||||
|
- 其余 `Views/ScenicPromotion*.swift` — 各业务子视图,均含 `#Preview`
|
||||||
|
|||||||
@ -286,6 +286,189 @@ enum ScenicPromotionMockStore {
|
|||||||
previewWidth: 96
|
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
|
||||||
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -140,6 +140,8 @@ final class ScenicPromotionSettingsViewModel: ObservableObject {
|
|||||||
@Published var selectedQueuePointIDs: Set<String> = []
|
@Published var selectedQueuePointIDs: Set<String> = []
|
||||||
@Published var selectedMaterialPointID: String?
|
@Published var selectedMaterialPointID: String?
|
||||||
@Published private(set) var materialsByPointID: [String: [ScenicPromotionManagedMaterial]] = [:]
|
@Published private(set) var materialsByPointID: [String: [ScenicPromotionManagedMaterial]] = [:]
|
||||||
|
/// 当前要预览的已上传素材。
|
||||||
|
@Published var previewMaterial: ScenicPromotionManagedMaterial?
|
||||||
|
|
||||||
/// 多选排队打卡点的展示文案。
|
/// 多选排队打卡点的展示文案。
|
||||||
var selectedQueuePointNames: String {
|
var selectedQueuePointNames: String {
|
||||||
@ -262,6 +264,11 @@ final class ScenicPromotionSettingsViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 打开素材预览页。
|
||||||
|
func openMaterialPreview(_ material: ScenicPromotionManagedMaterial) {
|
||||||
|
previewMaterial = material
|
||||||
|
}
|
||||||
|
|
||||||
/// 提交前校验至少绑定一个排队打卡点。
|
/// 提交前校验至少绑定一个排队打卡点。
|
||||||
func submit() -> Bool {
|
func submit() -> Bool {
|
||||||
guard !selectedQueuePointIDs.isEmpty else { return false }
|
guard !selectedQueuePointIDs.isEmpty else { return false }
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -1,789 +0,0 @@
|
|||||||
//
|
|
||||||
// ScenicPromotionViews.swift
|
|
||||||
// suixinkan
|
|
||||||
//
|
|
||||||
// Created by Codex on 2026/7/2.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// MARK: - 宣传页布局常量
|
|
||||||
|
|
||||||
private enum ScenicPromotionLayout {
|
|
||||||
// 主页面内容距离屏幕左右边缘各 20px,不代表模块之间的间距。
|
|
||||||
static let screenEdgePadding: CGFloat = 20
|
|
||||||
// 模块之间保持轻量垂直间距,避免误用 20px 做模块间隔。
|
|
||||||
static let moduleVerticalSpacing: CGFloat = 12
|
|
||||||
// 打卡点标签区最多一屏露出 5 个,更多时横向滚动。
|
|
||||||
static let visibleSpotTabCount: CGFloat = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 宣传页隐藏设置路由
|
|
||||||
|
|
||||||
private struct ScenicPromotionSettingsRoute: Identifiable, Hashable {
|
|
||||||
let id = "promotion_page_settings"
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 宣传页主视图
|
|
||||||
|
|
||||||
struct ScenicPromotionView: View {
|
|
||||||
// 页面主数据和交互状态都收敛到 ViewModel,View 只负责布局和事件转发。
|
|
||||||
@StateObject private var viewModel = ScenicPromotionViewModel()
|
|
||||||
// 二维码三连击后进入设置页,使用轻量本地路由控制跳转。
|
|
||||||
@State private var settingsRoute: ScenicPromotionSettingsRoute?
|
|
||||||
// 记录二维码短时间点击次数,用于触发隐藏密码入口。
|
|
||||||
@State private var qrTapCount = 0
|
|
||||||
@State private var qrTapResetTask: Task<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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -22,7 +22,8 @@ final class CooperationOrderAPITests: XCTestCase {
|
|||||||
XCTAssertEqual(response.page, 1)
|
XCTAssertEqual(response.page, 1)
|
||||||
XCTAssertEqual(response.pageSize, 50)
|
XCTAssertEqual(response.pageSize, 50)
|
||||||
XCTAssertEqual(response.list.first?.displayId, 101)
|
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?.displayPhone, "19281871413")
|
||||||
XCTAssertEqual(response.list.first?.bindingId, 5)
|
XCTAssertEqual(response.list.first?.bindingId, 5)
|
||||||
XCTAssertEqual(response.list.first?.shareCode, "INV-000101")
|
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])
|
let statusJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: statusBody) as? [String: Any])
|
||||||
XCTAssertEqual(statusJSON["status"] as? Int, 3)
|
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 请求。
|
/// URLSession 测试替身,记录合作订单 API 请求。
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
"binding_id": 5,
|
"binding_id": 5,
|
||||||
"bound_at": "2026-06-25 17:11:01",
|
"bound_at": "2026-06-25 17:11:01",
|
||||||
"name": "管铜彬",
|
"name": "管铜彬",
|
||||||
|
"photog_remark_name": "张哥",
|
||||||
"phone": "19281871413",
|
"phone": "19281871413",
|
||||||
"phone_masked": "192****1413",
|
"phone_masked": "192****1413",
|
||||||
"sale_user_id": 101,
|
"sale_user_id": 101,
|
||||||
|
|||||||
Reference in New Issue
Block a user