diff --git a/suixinkan/Assets.xcassets/ScenicQueue/Contents.json b/suixinkan/Assets.xcassets/ScenicQueue/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/suixinkan/Assets.xcassets/ScenicQueue/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/Contents.json b/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/Contents.json
new file mode 100644
index 0000000..f8834a3
--- /dev/null
+++ b/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images" : [
+ {
+ "filename" : "queue_location.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true
+ }
+}
diff --git a/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/queue_location.svg b/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/queue_location.svg
new file mode 100644
index 0000000..a1dc49f
--- /dev/null
+++ b/suixinkan/Assets.xcassets/ScenicQueue/queue_location.imageset/queue_location.svg
@@ -0,0 +1,3 @@
+
diff --git a/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift b/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift
index 36caf56..48f47be 100644
--- a/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift
+++ b/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift
@@ -28,6 +28,20 @@ struct ScenicQueueHomeData: Decodable, Equatable {
/// 排队 home 接口 stats 节点。
struct ScenicQueueHomeStats: Decodable, Equatable {
let type: Int
+
+ /// 创建 stats 节点;Android DTO 在服务端未返回 type 时默认使用 0。
+ init(type: Int = 0) {
+ self.type = type
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ }
}
/// 排队 home 接口分页列表节点。
@@ -426,6 +440,43 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Hashable {
}
}
+extension ScenicQueueSettingChangeLogItem {
+ /// Android 日志卡片的单行元信息:时间、操作人和手机号尾号。
+ var metadataLine: String {
+ let time: String
+ if let createdAt, !createdAt.isEmpty {
+ let input = DateFormatter()
+ input.locale = Locale(identifier: "en_US_POSIX")
+ input.timeZone = TimeZone(identifier: "Asia/Shanghai")
+ let formats = ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ssXXXXX", "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"]
+ let parsedDate = formats.lazy.compactMap { format -> Date? in
+ input.dateFormat = format
+ return input.date(from: createdAt)
+ }.first
+ if let date = parsedDate {
+ let output = DateFormatter()
+ output.locale = Locale(identifier: "en_US_POSIX")
+ output.timeZone = TimeZone(identifier: "Asia/Shanghai")
+ output.dateFormat = "yyyy-MM-dd HH:mm"
+ time = output.string(from: date)
+ } else {
+ time = String(createdAt.replacingOccurrences(of: "T", with: " ").prefix(16))
+ }
+ } else {
+ time = "—"
+ }
+ let name = operatorName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "未知" : operatorName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let tail = operatorPhoneTail.trimmingCharacters(in: .whitespacesAndNewlines)
+ return tail.isEmpty ? "\(time) \(name)" : "\(time) \(name) 尾号\(tail)"
+ }
+
+ /// 日志卡片主摘要,优先使用 summary,空时回退 displayText。
+ var cardSummary: String {
+ let value = summary.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? displayText : value
+ }
+}
+
/// 打卡点列表响应。
struct ScenicSpotListResponse: Decodable, Equatable {
let list: [ScenicSpotItem]
diff --git a/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift b/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift
index 5d70f76..38091d3 100644
--- a/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift
+++ b/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift
@@ -6,11 +6,56 @@
import Foundation
/// 当前排队列表中的叫号状态。
-enum ScenicQueueCallStatus: Equatable {
+enum ScenicQueueCallStatus: Hashable {
case waiting
case called
}
+/// 当前排队卡片在指定拍摄会话与设置下的操作展示状态。
+struct ScenicQueueTicketActionState: Equatable {
+ let skipEnabled: Bool
+ let primaryVisible: Bool
+ let primaryEnabled: Bool
+ let primaryTitle: String
+ let recallVisible: Bool
+ let completeVisible: Bool
+
+ /// 按 Android 排队卡片规则生成操作状态。
+ static func make(
+ ticket: ScenicQueueTicket,
+ shootingQueueNo: String?,
+ remainSeconds: Int,
+ showStartShootingButton: Bool
+ ) -> ScenicQueueTicketActionState {
+ let isShooting = shootingQueueNo == ticket.queueNo
+ if isShooting {
+ return ScenicQueueTicketActionState(
+ skipEnabled: false,
+ primaryVisible: true,
+ primaryEnabled: false,
+ primaryTitle: "剩余 \(max(0, remainSeconds)) 秒",
+ recallVisible: false,
+ completeVisible: ticket.showCompleteAction
+ )
+ }
+ let primaryVisible = ticket.status == .waiting || showStartShootingButton
+ return ScenicQueueTicketActionState(
+ skipEnabled: true,
+ primaryVisible: primaryVisible,
+ primaryEnabled: true,
+ primaryTitle: ticket.status == .waiting ? "叫号" : "开始拍摄",
+ recallVisible: ticket.status == .called,
+ completeVisible: ticket.showCompleteAction
+ )
+ }
+}
+
+/// 排队首页 Diffable Data Source 的稳定列表项。
+enum ScenicQueueListItem: Hashable {
+ case current(ScenicQueueTicket)
+ case skipped(ScenicQueueSkippedTicket)
+}
+
/// 当前排队列表展示实体。
struct ScenicQueueTicket: Hashable {
let recordId: Int64
diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift
index 601b91f..7d25b10 100644
--- a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift
+++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift
@@ -15,6 +15,7 @@ final class ScenicQueueSettingChangeLogViewModel {
private(set) var isRefreshing = false
private(set) var isLoadingMore = false
private(set) var canLoadMore = false
+ private(set) var initialLoading = true
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@@ -50,6 +51,7 @@ final class ScenicQueueSettingChangeLogViewModel {
}
notifyStateChange()
defer {
+ initialLoading = false
if append { isLoadingMore = false } else { isRefreshing = false }
notifyStateChange()
}
diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift
index e256059..bb8e6e6 100644
--- a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift
+++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift
@@ -47,6 +47,7 @@ final class ScenicQueueSettingsViewModel {
private(set) var presetVoices: [String]
private(set) var settingsTab: ScenicQueueSettingsTab = .basic
private(set) var highlightedInvalidFieldKeys: Set = []
+ private(set) var blockingInvalidNumericFieldKeys: Set = []
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@@ -56,7 +57,7 @@ final class ScenicQueueSettingsViewModel {
private let appStore: AppStore
private let ttsManager: ScenicQueueTTSManaging
private var punchSpotSettingRequestGeneration = 0
- private var ttsLoopAnchorText: String?
+ private(set) var ttsLoopAnchorText: String?
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
self.appStore = appStore
@@ -108,6 +109,30 @@ final class ScenicQueueSettingsViewModel {
appStore.scenicQueue.useOfflineTTS
}
+ /// 自定义文案或预设文案是否正在循环播报。
+ var isCustomTTSLooping: Bool {
+ ttsManager.customTextLooping
+ }
+
+ /// 将米格式化为 Android 设置页使用的千米文本,最多保留两位小数。
+ nonisolated static func kilometersDisplay(fromMeters meters: Int) -> String {
+ let value = Double(max(0, meters)) / 1_000
+ if value.rounded() == value { return String(format: "%.0f", value) }
+ let text = String(format: "%.2f", value)
+ return text.replacingOccurrences(of: #"0+$"#, with: "", options: .regularExpression)
+ .replacingOccurrences(of: #"\.$"#, with: "", options: .regularExpression)
+ }
+
+ /// 将最多两位小数的千米文本转换为米;非法格式或越界时返回 nil。
+ nonisolated static func meters(fromKilometersText raw: String) -> Int? {
+ let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard text.range(of: #"^\d{1,4}(\.\d{0,2})?$"#, options: .regularExpression) != nil,
+ let value = Decimal(string: text, locale: Locale(identifier: "en_US_POSIX")) else { return nil }
+ let meters = NSDecimalNumber(decimal: value * 1_000).intValue
+ guard Limits.queueDistance.contains(meters) else { return nil }
+ return meters
+ }
+
func openSettingChangeLog() {
onNavigateLog?()
}
@@ -146,6 +171,18 @@ final class ScenicQueueSettingsViewModel {
notifyStateChange()
}
+ /// 记录数字输入框是否存在尚未修正的越界原始值,并同步红框状态。
+ func reportOutOfRangeNumericPending(fieldKey: String, pending: Bool) {
+ if pending {
+ blockingInvalidNumericFieldKeys.insert(fieldKey)
+ highlightedInvalidFieldKeys.insert(fieldKey)
+ } else {
+ blockingInvalidNumericFieldKeys.remove(fieldKey)
+ highlightedInvalidFieldKeys.remove(fieldKey)
+ }
+ notifyStateChange()
+ }
+
func setShootMinute(_ value: Int) { shootMinute = value.clamped(to: Limits.shootMinute); notifyStateChange() }
func setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); notifyStateChange() }
func setFirstAheadCount(_ value: Int) { firstAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
@@ -222,6 +259,21 @@ final class ScenicQueueSettingsViewModel {
}
}
+ /// 循环播放指定预设文案;若已有文案播放则先停止再切换。
+ func startPresetVoiceLoop(_ phrase: String) {
+ let text = String(phrase.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Limits.remarkMaxLen))
+ guard !text.isEmpty else { return }
+ Task {
+ if ttsManager.customTextLooping {
+ ttsManager.stopCustomTextLoop()
+ }
+ let ok = await ttsManager.startCustomTextLoop(text)
+ ttsLoopAnchorText = ok ? text : nil
+ if !ok { onShowMessage?("播报失败,请检查网络与语音服务") }
+ notifyStateChange()
+ }
+ }
+
/// 播放蓝牙测试音。
func playBluetoothTestSound() {
Task {
@@ -289,7 +341,7 @@ final class ScenicQueueSettingsViewModel {
/// 校验并保存设置。
func save(api: ScenicQueueAPIProtocol) async {
- highlightedInvalidFieldKeys = []
+ highlightedInvalidFieldKeys = blockingInvalidNumericFieldKeys
guard let ids = currentScenicAndSpotIdsForSelected() else {
highlightedInvalidFieldKeys = ["punchSpot"]
settingsTab = .basic
@@ -297,6 +349,12 @@ final class ScenicQueueSettingsViewModel {
notifyStateChange()
return
}
+ guard blockingInvalidNumericFieldKeys.isEmpty else {
+ settingsTab = tabForValidationKeys(blockingInvalidNumericFieldKeys)
+ onShowMessage?("请修正标红的配置项")
+ notifyStateChange()
+ return
+ }
let validation = buildSaveValidation()
guard validation.messages.isEmpty else {
highlightedInvalidFieldKeys = validation.keys
diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift
index 9c51833..b90a710 100644
--- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift
+++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift
@@ -194,13 +194,11 @@ final class WildPhotographerReportSubmitViewModel {
/// 限制举报说明最多 500 字。
func updateDescription(_ newValue: String) {
description = String(newValue.prefix(500))
- notifyStateChange()
}
/// 更新联系方式。
func updateContact(_ value: String) {
contact = String(value.prefix(80))
- notifyStateChange()
}
/// 添加本地现场图片。
diff --git a/suixinkan/Theme/Modules/ScenicQueueTokens.swift b/suixinkan/Theme/Modules/ScenicQueueTokens.swift
index 89f6067..00e4dc2 100644
--- a/suixinkan/Theme/Modules/ScenicQueueTokens.swift
+++ b/suixinkan/Theme/Modules/ScenicQueueTokens.swift
@@ -31,6 +31,13 @@ enum ScenicQueueTokens {
static let dialogDangerLine = UIColor(hex: 0xD32F2F)
static let fieldBorderMuted = UIColor(hex: 0xE5E7EB)
static let settingsMutedBlock = UIColor(hex: 0xF7F8FA)
+ static let dialogCancelBackground = UIColor(hex: 0xEAF1F8)
+ static let markDialogCancelBackground = UIColor(hex: 0xF0F7FF)
+ static let markDialogDivider = UIColor(hex: 0xF0F0F0)
+ static let markDialogStepperBackground = UIColor(hex: 0xF2F4F7)
+ static let logCardBackground = UIColor(hex: 0xF7F8FA)
+ static let timelineLine = UIColor(hex: 0xD8E8FF)
+ static let timelineDot = UIColor(hex: 0x1677FF)
static let radiusContentSheetTop: CGFloat = 14
static let radiusCard: CGFloat = 12
@@ -43,6 +50,9 @@ enum ScenicQueueTokens {
static let listItemGap: CGFloat = 10
static let listHorizontalInset: CGFloat = 12
static let bottomSaveButtonRadius: CGFloat = 12
+ static let markDialogRadius: CGFloat = 22
+ static let markDialogButtonRadius: CGFloat = 12
+ static let dialogHorizontalInset: CGFloat = 22
static let bannerLabelFont = UIFont.systemFont(ofSize: 12)
static let bannerValueFont = UIFont.systemFont(ofSize: 30, weight: .bold)
diff --git a/suixinkan/UI/Common/UIButton+Configuration.swift b/suixinkan/UI/Common/UIButton+Configuration.swift
index 847d96a..90f1969 100644
--- a/suixinkan/UI/Common/UIButton+Configuration.swift
+++ b/suixinkan/UI/Common/UIButton+Configuration.swift
@@ -14,4 +14,12 @@ extension UIButton {
updatedConfiguration.contentInsets = contentInsets
configuration = updatedConfiguration
}
+
+ /// 设置图片相对标题的位置和间距,并保留按钮已有的 configuration 内容。
+ func setConfigurationImagePlacement(_ imagePlacement: NSDirectionalRectEdge, padding: CGFloat) {
+ var updatedConfiguration = configuration ?? .plain()
+ updatedConfiguration.imagePlacement = imagePlacement
+ updatedConfiguration.imagePadding = padding
+ configuration = updatedConfiguration
+ }
}
diff --git a/suixinkan/UI/Home/ScenicSelectionViewController.swift b/suixinkan/UI/Home/ScenicSelectionViewController.swift
index 46f059c..2570c5a 100644
--- a/suixinkan/UI/Home/ScenicSelectionViewController.swift
+++ b/suixinkan/UI/Home/ScenicSelectionViewController.swift
@@ -20,9 +20,9 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
- init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) {
+ init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI? = nil) {
self.viewModel = viewModel
- self.homeAPI = homeAPI
+ self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift
index bde0a1b..96a6f79 100644
--- a/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift
+++ b/suixinkan/UI/Home/Views/HomeWorkStatusCardView.swift
@@ -58,8 +58,7 @@ final class HomeWorkStatusCardView: UIView {
let reminderIconConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
reminderButton.setImage(UIImage(systemName: "bell.fill", withConfiguration: reminderIconConfig), for: .normal)
reminderButton.semanticContentAttribute = .forceLeftToRight
- reminderButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 6)
- reminderButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 6, bottom: 0, right: -6)
+ reminderButton.setConfigurationImagePlacement(.leading, padding: 6)
reminderButton.titleLabel?.adjustsFontSizeToFitWidth = true
reminderButton.titleLabel?.minimumScaleFactor = 0.8
reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside)
diff --git a/suixinkan/UI/Invite/InviteRecordViewController.swift b/suixinkan/UI/Invite/InviteRecordViewController.swift
index 7dafcd6..3347506 100644
--- a/suixinkan/UI/Invite/InviteRecordViewController.swift
+++ b/suixinkan/UI/Invite/InviteRecordViewController.swift
@@ -38,11 +38,11 @@ final class InviteRecordViewController: BaseViewController {
/// 初始化邀请记录页面。
init(
- inviteAPI: InviteServing = NetworkServices.shared.inviteAPI,
- walletAPI: WalletServing = NetworkServices.shared.walletAPI
+ inviteAPI: InviteServing? = nil,
+ walletAPI: WalletServing? = nil
) {
- self.inviteAPI = inviteAPI
- self.walletAPI = walletAPI
+ self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI
+ self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/Invite/PhotographerInviteViewController.swift b/suixinkan/UI/Invite/PhotographerInviteViewController.swift
index 0339be4..596f0cb 100644
--- a/suixinkan/UI/Invite/PhotographerInviteViewController.swift
+++ b/suixinkan/UI/Invite/PhotographerInviteViewController.swift
@@ -19,8 +19,8 @@ final class PhotographerInviteViewController: BaseViewController {
private let ruleStack = UIStackView()
/// 初始化摄影师邀请页面。
- init(inviteAPI: InviteServing = NetworkServices.shared.inviteAPI) {
- self.inviteAPI = inviteAPI
+ init(inviteAPI: InviteServing? = nil) {
+ self.inviteAPI = inviteAPI ?? NetworkServices.shared.inviteAPI
super.init(nibName: nil, bundle: nil)
}
@@ -234,7 +234,7 @@ final class PhotographerInviteViewController: BaseViewController {
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.setImage(UIImage(named: "ic_invite_record")?.withRenderingMode(.alwaysTemplate), for: .normal)
button.tintColor = AppColor.primary
- button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -6, bottom: 0, right: 6)
+ button.setConfigurationImagePlacement(.leading, padding: 6)
button.addTarget(self, action: #selector(inviteRecordTapped), for: .touchUpInside)
container.addSubview(button)
button.snp.makeConstraints { make in
@@ -360,8 +360,7 @@ private final class InviteIconButton: UIButton {
setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate), for: .normal)
tintColor = .white
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
- imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
- titleEdgeInsets = UIEdgeInsets(top: 0, left: 3, bottom: 0, right: -3)
+ setConfigurationImagePlacement(.leading, padding: 6)
}
@available(*, unavailable)
diff --git a/suixinkan/UI/Live/LiveAlbumViewControllers.swift b/suixinkan/UI/Live/LiveAlbumViewControllers.swift
index fea7cb8..b1a753a 100644
--- a/suixinkan/UI/Live/LiveAlbumViewControllers.swift
+++ b/suixinkan/UI/Live/LiveAlbumViewControllers.swift
@@ -207,7 +207,7 @@ final class LiveAlbumListViewController: BaseViewController, UITableViewDelegate
button.titleLabel?.font = .systemFont(ofSize: 13)
button.setImage(UIImage(systemName: "calendar"), for: .normal)
button.tintColor = AppColor.textPrimary
- button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
+ button.setConfigurationImagePlacement(.leading, padding: 4)
setDateButtonTitle(button, text: title)
}
diff --git a/suixinkan/UI/Live/LiveUIComponents.swift b/suixinkan/UI/Live/LiveUIComponents.swift
index ed5455c..ac65070 100644
--- a/suixinkan/UI/Live/LiveUIComponents.swift
+++ b/suixinkan/UI/Live/LiveUIComponents.swift
@@ -75,7 +75,7 @@ final class LiveActionButton: UIButton {
layer.cornerRadius = 8
clipsToBounds = true
tintColor = .white
- imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
+ setConfigurationImagePlacement(.leading, padding: 4)
updateAppearance()
}
diff --git a/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift b/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift
index 0bd346f..4e212cd 100644
--- a/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift
+++ b/suixinkan/UI/LocationReport/LocationReportHistoryViewController.swift
@@ -20,10 +20,10 @@ final class LocationReportHistoryViewController: BaseViewController, UITableView
init(
viewModel: LocationReportHistoryViewModel = LocationReportHistoryViewModel(),
- homeAPI: HomeAPI = NetworkServices.shared.homeAPI
+ homeAPI: HomeAPI? = nil
) {
self.viewModel = viewModel
- self.homeAPI = homeAPI
+ self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/LocationReport/LocationReportViewController.swift b/suixinkan/UI/LocationReport/LocationReportViewController.swift
index 488b7c8..ee3ee95 100644
--- a/suixinkan/UI/LocationReport/LocationReportViewController.swift
+++ b/suixinkan/UI/LocationReport/LocationReportViewController.swift
@@ -24,10 +24,10 @@ final class LocationReportViewController: BaseViewController {
init(
viewModel: LocationReportViewModel = LocationReportViewModel(),
- homeAPI: HomeAPI = NetworkServices.shared.homeAPI
+ homeAPI: HomeAPI? = nil
) {
self.viewModel = viewModel
- self.homeAPI = homeAPI
+ self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift
index b691f4a..05a1543 100644
--- a/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift
+++ b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift
@@ -30,10 +30,10 @@ final class MaterialDetailViewController: BaseViewController {
/// 初始化素材详情页。
init(
materialId: Int,
- api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
+ api: (any MaterialManagementServing)? = nil
) {
viewModel = MaterialDetailViewModel(materialId: materialId)
- self.api = api
+ self.api = api ?? NetworkServices.shared.materialManagementAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift
index b52737c..4c72e74 100644
--- a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift
+++ b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift
@@ -49,12 +49,12 @@ final class MaterialFormViewController: BaseViewController {
/// 初始化素材表单页。
init(
mode: MaterialFormViewModel.Mode,
- api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI,
- uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService
+ api: (any MaterialManagementServing)? = nil,
+ uploader: (any MaterialOSSUploading)? = nil
) {
viewModel = MaterialFormViewModel(mode: mode)
- self.api = api
- self.uploader = uploader
+ self.api = api ?? NetworkServices.shared.materialManagementAPI
+ self.uploader = uploader ?? NetworkServices.shared.ossUploadService
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift
index a4b298e..9997adf 100644
--- a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift
+++ b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift
@@ -33,10 +33,10 @@ final class MaterialListViewController: BaseViewController {
/// 初始化素材管理列表页。
init(
viewModel: MaterialListViewModel = MaterialListViewModel(),
- api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
+ api: (any MaterialManagementServing)? = nil
) {
self.viewModel = viewModel
- self.api = api
+ self.api = api ?? NetworkServices.shared.materialManagementAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/MessageCenter/MessageCenterViewController.swift b/suixinkan/UI/MessageCenter/MessageCenterViewController.swift
index f394c8a..6d5695c 100644
--- a/suixinkan/UI/MessageCenter/MessageCenterViewController.swift
+++ b/suixinkan/UI/MessageCenter/MessageCenterViewController.swift
@@ -25,10 +25,10 @@ final class MessageCenterViewController: BaseViewController, UITableViewDelegate
/// 初始化消息中心列表页。
init(
viewModel: MessageCenterViewModel = MessageCenterViewModel(),
- api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI
+ api: (any MessageCenterServing)? = nil
) {
self.viewModel = viewModel
- self.api = api
+ self.api = api ?? NetworkServices.shared.messageCenterAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/MessageCenter/MessageDetailViewController.swift b/suixinkan/UI/MessageCenter/MessageDetailViewController.swift
index ddc3f73..8b066b2 100644
--- a/suixinkan/UI/MessageCenter/MessageDetailViewController.swift
+++ b/suixinkan/UI/MessageCenter/MessageDetailViewController.swift
@@ -24,9 +24,9 @@ final class MessageDetailViewController: BaseViewController {
private let deleteButton = UIButton(type: .system)
/// 初始化消息详情页。
- init(message: MessageItem, api: any MessageCenterServing = NetworkServices.shared.messageCenterAPI) {
+ init(message: MessageItem, api: (any MessageCenterServing)? = nil) {
viewModel = MessageDetailViewModel(message: message)
- self.api = api
+ self.api = api ?? NetworkServices.shared.messageCenterAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/Orders/DepositOrderDetailViewController.swift b/suixinkan/UI/Orders/DepositOrderDetailViewController.swift
index d0a0a44..3166c83 100644
--- a/suixinkan/UI/Orders/DepositOrderDetailViewController.swift
+++ b/suixinkan/UI/Orders/DepositOrderDetailViewController.swift
@@ -14,9 +14,9 @@ final class DepositOrderDetailViewController: BaseViewController, UITableViewDat
private var detail: StoreOrderDetailData?
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
- init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
+ init(orderNumber: String, orderAPI: OrderAPI? = nil) {
self.orderNumber = orderNumber
- self.orderAPI = orderAPI
+ self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift b/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift
index 9dff993..961c0d7 100644
--- a/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift
+++ b/suixinkan/UI/Orders/HistoricalShootingInfoViewController.swift
@@ -14,9 +14,9 @@ final class HistoricalShootingInfoViewController: BaseViewController, UITableVie
private var response: MultiTravelShootHistoryResponse?
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
- init(orderNumber: String, orderAPI: OrderAPI = NetworkServices.shared.orderAPI) {
+ init(orderNumber: String, orderAPI: OrderAPI? = nil) {
self.orderNumber = orderNumber
- self.orderAPI = orderAPI
+ self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift
index 19aeebd..26564a2 100644
--- a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift
+++ b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift
@@ -53,10 +53,10 @@ final class ProfileSpaceSettingsViewController: BaseViewController {
init(
viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(),
- profileAPI: ProfileAPI = NetworkServices.shared.profileAPI
+ profileAPI: ProfileAPI? = nil
) {
self.viewModel = viewModel
- self.profileAPI = profileAPI
+ self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/SampleManagement/SampleDetailViewController.swift b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift
index fe1c1ea..490a2ad 100644
--- a/suixinkan/UI/SampleManagement/SampleDetailViewController.swift
+++ b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift
@@ -26,10 +26,10 @@ final class SampleDetailViewController: SampleBaseViewController {
/// 初始化样片详情页。
init(
sampleId: Int,
- api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
+ api: (any SampleManagementServing)? = nil
) {
viewModel = SampleDetailViewModel(sampleId: sampleId)
- self.api = api
+ self.api = api ?? NetworkServices.shared.sampleManagementAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/SampleManagement/SampleListViewController.swift b/suixinkan/UI/SampleManagement/SampleListViewController.swift
index c57ddd8..1cb207a 100644
--- a/suixinkan/UI/SampleManagement/SampleListViewController.swift
+++ b/suixinkan/UI/SampleManagement/SampleListViewController.swift
@@ -32,10 +32,10 @@ final class SampleListViewController: SampleBaseViewController {
/// 初始化样片列表页。
init(
viewModel: SampleListViewModel = SampleListViewModel(),
- api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
+ api: (any SampleManagementServing)? = nil
) {
self.viewModel = viewModel
- self.api = api
+ self.api = api ?? NetworkServices.shared.sampleManagementAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift
index 9a472b1..c2b10e8 100644
--- a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift
+++ b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift
@@ -51,12 +51,12 @@ final class UploadSampleViewController: SampleBaseViewController {
/// 初始化样片上传页。
init(
viewModel: UploadSampleViewModel = UploadSampleViewModel(),
- api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI,
- uploader: any SampleOSSUploading = NetworkServices.shared.ossUploadService
+ api: (any SampleManagementServing)? = nil,
+ uploader: (any SampleOSSUploading)? = nil
) {
self.viewModel = viewModel
- self.api = api
- self.uploader = uploader
+ self.api = api ?? NetworkServices.shared.sampleManagementAPI
+ self.uploader = uploader ?? NetworkServices.shared.ossUploadService
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift b/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift
index a634518..675e20f 100644
--- a/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift
+++ b/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift
@@ -41,14 +41,14 @@ final class ScenicApplicationViewController: BaseViewController {
init(
viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(),
- homeAPI: HomeAPI = NetworkServices.shared.homeAPI,
- profileAPI: ProfileAPI = NetworkServices.shared.profileAPI,
- ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService
+ homeAPI: HomeAPI? = nil,
+ profileAPI: ProfileAPI? = nil,
+ ossUploadService: OSSUploadService? = nil
) {
self.viewModel = viewModel
- self.homeAPI = homeAPI
- self.profileAPI = profileAPI
- self.ossUploadService = ossUploadService
+ self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
+ self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
+ self.ossUploadService = ossUploadService ?? NetworkServices.shared.ossUploadService
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift
index 3c1feb4..34b1fd9 100644
--- a/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift
+++ b/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift
@@ -6,12 +6,15 @@
import SnapKit
import UIKit
-/// 排队设置配置日志页。
+/// 排队配置日志页,按 Android 时间轴样式展示变更历史。
final class ScenicQueueSettingChangeLogViewController: BaseViewController {
private let viewModel = ScenicQueueSettingChangeLogViewModel()
private let api: ScenicQueueAPIProtocol
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
+ private let loadingIndicator = UIActivityIndicatorView(style: .medium)
+ private let footerLabel = UILabel()
+ private var dataSource: UITableViewDiffableDataSource!
@MainActor
init(api: ScenicQueueAPIProtocol? = nil) {
@@ -20,133 +23,142 @@ final class ScenicQueueSettingChangeLogViewController: BaseViewController {
}
@available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.refresh(api: api) }
}
- override func setupNavigationBar() {
- title = "配置日志"
- }
+ override func setupNavigationBar() { title = "配置日志" }
override func setupUI() {
- view.backgroundColor = ScenicQueueTokens.pageBackground
- tableView.backgroundColor = .clear
+ view.backgroundColor = .white
+ tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
- tableView.estimatedRowHeight = 120
+ tableView.estimatedRowHeight = 132
tableView.delegate = self
- tableView.dataSource = self
- tableView.register(ScenicQueueLogCell.self, forCellReuseIdentifier: ScenicQueueLogCell.reuseIdentifier)
+ tableView.register(ScenicQueueLogTimelineCell.self, forCellReuseIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier)
tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
+ dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
+ let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogTimelineCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogTimelineCell
+ cell.configure(item: item, showDot: self?.shouldShowDot(at: indexPath.row) ?? false)
+ return cell
+ }
emptyLabel.text = "暂无配置变更记录"
- emptyLabel.textColor = AppColor.textTertiary
+ emptyLabel.textColor = AppColor.textSecondary
emptyLabel.font = ScenicQueueTokens.emptyStateFont
emptyLabel.textAlignment = .center
+ loadingIndicator.color = ScenicQueueTokens.bannerBlue
+ footerLabel.textAlignment = .center
+ footerLabel.textColor = AppColor.textSecondary
+ footerLabel.font = .systemFont(ofSize: 14)
view.addSubview(tableView)
view.addSubview(emptyLabel)
+ view.addSubview(loadingIndicator)
}
override func setupConstraints() {
- tableView.snp.makeConstraints { make in
- make.edges.equalTo(view.safeAreaLayoutGuide)
- }
- emptyLabel.snp.makeConstraints { make in
- make.center.equalToSuperview()
- }
+ tableView.snp.makeConstraints { $0.edges.equalTo(view.safeAreaLayoutGuide) }
+ emptyLabel.snp.makeConstraints { $0.center.equalToSuperview() }
+ loadingIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
}
override func bindActions() {
- viewModel.onStateChange = { [weak self] in
- Task { @MainActor in self?.applyState() }
- }
- viewModel.onShowMessage = { [weak self] message in
- Task { @MainActor in self?.showToast(message) }
- }
+ viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
+ viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
}
private func applyState() {
tableView.refreshControl?.endRefreshing()
- tableView.reloadData()
- emptyLabel.isHidden = !viewModel.items.isEmpty
+ viewModel.initialLoading && viewModel.items.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating()
+ emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([0])
+ snapshot.appendItems(viewModel.items)
+ dataSource.apply(snapshot, animatingDifferences: true)
+ if viewModel.items.isEmpty {
+ tableView.tableFooterView = UIView()
+ } else {
+ footerLabel.text = viewModel.isLoadingMore ? "加载中…" : (viewModel.canLoadMore ? "" : "没有更多")
+ footerLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
+ tableView.tableFooterView = footerLabel
+ }
}
- @objc private func refreshPulled() {
- Task { await viewModel.refresh(api: api) }
+ private func shouldShowDot(at index: Int) -> Bool {
+ let estimatedRowHeight: CGFloat = 132
+ let itemsPerThird = max(1, Int(((view.bounds.height / 3) / estimatedRowHeight).rounded()))
+ let stride = max(3, min(12, itemsPerThird))
+ return index % stride == 0
}
+
+ @objc private func refreshPulled() { Task { await viewModel.refresh(api: api) } }
}
-extension ScenicQueueSettingChangeLogViewController: UITableViewDataSource, UITableViewDelegate {
- func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
- viewModel.items.count
- }
-
- func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueLogCell.reuseIdentifier, for: indexPath) as! ScenicQueueLogCell
- cell.configure(item: viewModel.items[indexPath.row])
- return cell
- }
-
+extension ScenicQueueSettingChangeLogViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
- guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
+ guard !viewModel.items.isEmpty,
+ scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else { return }
Task { await viewModel.loadMore(api: api) }
}
}
-/// 配置日志 cell。
-final class ScenicQueueLogCell: UITableViewCell {
- static let reuseIdentifier = "ScenicQueueLogCell"
-
+/// 配置日志的时间轴卡片单元格。
+final class ScenicQueueLogTimelineCell: UITableViewCell {
+ static let reuseIdentifier = "ScenicQueueLogTimelineCell"
+ private let rail = UIView()
+ private let dot = UIView()
private let card = UIView()
- private let titleLabel = UILabel()
- private let detailLabel = UILabel()
- private let timeLabel = UILabel()
+ private let metaLabel = UILabel()
+ private let summaryLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
- setupUI()
+ selectionStyle = .none
+ backgroundColor = .white
+ rail.backgroundColor = ScenicQueueTokens.timelineLine
+ dot.backgroundColor = ScenicQueueTokens.timelineDot
+ dot.layer.cornerRadius = 4
+ card.backgroundColor = ScenicQueueTokens.logCardBackground
+ card.layer.cornerRadius = 12
+ metaLabel.font = .systemFont(ofSize: 12)
+ metaLabel.textColor = AppColor.textSecondary
+ metaLabel.numberOfLines = 3
+ summaryLabel.font = .systemFont(ofSize: 15, weight: .semibold)
+ summaryLabel.textColor = AppColor.textPrimary
+ summaryLabel.numberOfLines = 0
+ let stack = UIStackView(arrangedSubviews: [metaLabel, summaryLabel])
+ stack.axis = .vertical
+ stack.spacing = 8
+ contentView.addSubview(rail)
+ contentView.addSubview(dot)
+ contentView.addSubview(card)
+ card.addSubview(stack)
+ rail.snp.makeConstraints { make in
+ make.centerX.equalTo(contentView.snp.leading).offset(UIScreen.main.bounds.width * 0.10)
+ make.top.bottom.equalToSuperview()
+ make.width.equalTo(1.5)
+ }
+ dot.snp.makeConstraints { make in make.centerX.equalTo(rail); make.top.equalToSuperview().offset(18); make.size.equalTo(8) }
+ card.snp.makeConstraints { make in
+ make.leading.equalTo(rail.snp.trailing).offset(20)
+ make.trailing.equalToSuperview().offset(-16)
+ make.top.equalToSuperview().offset(6)
+ make.bottom.equalToSuperview().offset(-6)
+ }
+ stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(15) }
}
@available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
- func configure(item: ScenicQueueSettingChangeLogItem) {
- titleLabel.text = item.operatorName.isEmpty ? "配置变更" : item.operatorName
- detailLabel.text = item.displayText.isEmpty ? item.summary : item.displayText
- timeLabel.text = item.createdAt
- }
-
- private func setupUI() {
- selectionStyle = .none
- backgroundColor = .clear
- card.backgroundColor = .white
- card.layer.cornerRadius = ScenicQueueTokens.radiusCard
- card.layer.borderWidth = 1
- card.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
- contentView.addSubview(card)
- titleLabel.font = ScenicQueueTokens.settingsSectionTitleFont
- titleLabel.textColor = AppColor.textPrimary
- detailLabel.font = ScenicQueueTokens.settingsRowTitleFont
- detailLabel.textColor = AppColor.textSecondary
- detailLabel.numberOfLines = 0
- timeLabel.font = ScenicQueueTokens.settingsHintFont
- timeLabel.textColor = AppColor.textTertiary
- let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, timeLabel])
- stack.axis = .vertical
- stack.spacing = 8
- card.addSubview(stack)
- card.snp.makeConstraints { make in
- make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
- }
- stack.snp.makeConstraints { make in
- make.edges.equalToSuperview().inset(14)
- }
+ /// 绑定日志内容与时间轴圆点显隐。
+ func configure(item: ScenicQueueSettingChangeLogItem, showDot: Bool) {
+ metaLabel.text = item.metadataLine
+ summaryLabel.text = item.cardSummary
+ dot.isHidden = !showDot
}
}
diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift
index 1d064e8..3f33230 100644
--- a/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift
+++ b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift
@@ -6,107 +6,108 @@
import SnapKit
import UIKit
-/// 排队设置页,对齐 Android `ScenicQueueSettingsScreen` 的三 Tab 表单。
+/// 排队设置页,按 Android `ScenicQueueSettingsScreen` 提供三页持久化表单。
final class ScenicQueueSettingsViewController: BaseViewController {
- private let viewModel = ScenicQueueSettingsViewModel()
+ private let viewModel: ScenicQueueSettingsViewModel
private let api: ScenicQueueAPIProtocol
-
- private let segmentedControl = UISegmentedControl(items: ScenicQueueSettingsTab.allCases.map(\.title))
- private let scrollView = UIScrollView()
- private let stackView = UIStackView()
+ private let tabStrip = ScenicQueueTabStripView(
+ titles: ScenicQueueSettingsTab.allCases.map(\.title))
+ private let pageController = UIPageViewController(
+ transitionStyle: .scroll, navigationOrientation: .horizontal)
private let saveBar = UIView()
private let saveButton = UIButton(type: .system)
-
- private var fieldRows: [String: ScenicQueueSettingFieldRow] = [:]
- private var switchRows: [String: ScenicQueueSwitchRow] = [:]
+ private var pages: [ScenicQueueSettingsPageController] = []
+ private var numericFields: [String: ScenicQueueNumericFieldView] = [:]
+ private var switches: [String: ScenicQueueSwitchRow] = [:]
+ private let punchSpotButton = UIButton(type: .system)
+ private var selectedPunchLabel = UILabel()
+ private var punchSpotOptionsLoading = false
+ private var punchSpotOptionsLoaded = false
+ private var startTimeButton = UIButton(type: .system)
+ private var endTimeButton = UIButton(type: .system)
+ private var customTextView = UITextView()
+ private var customPlayButton = UIButton(type: .system)
+ private var presetStack = UIStackView()
+ private var presetSummaryLabel = UILabel()
@MainActor
- init(api: ScenicQueueAPIProtocol? = nil) {
+ init(
+ api: ScenicQueueAPIProtocol? = nil,
+ viewModel: ScenicQueueSettingsViewModel? = nil
+ ) {
self.api = api ?? NetworkServices.shared.scenicQueueAPI
+ self.viewModel = viewModel ?? ScenicQueueSettingsViewModel()
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupNavigationBar() {
title = "设置"
navigationItem.rightBarButtonItem = UIBarButtonItem(
- title: "配置日志",
- style: .plain,
- target: self,
- action: #selector(logTapped)
- )
+ title: "配置日志", style: .plain, target: self, action: #selector(logTapped))
}
override func setupUI() {
view.backgroundColor = ScenicQueueTokens.pageBackground
- segmentedControl.selectedSegmentIndex = viewModel.settingsTab.rawValue
- segmentedControl.selectedSegmentTintColor = .white
- segmentedControl.setTitleTextAttributes([
- .font: ScenicQueueTokens.settingsRowTitleFont,
- .foregroundColor: AppColor.textSecondary,
- ], for: .normal)
- segmentedControl.setTitleTextAttributes([
- .font: UIFont.systemFont(ofSize: 14, weight: .semibold),
- .foregroundColor: ScenicQueueTokens.bannerBlue,
- ], for: .selected)
+ pageController.dataSource = self
+ pageController.delegate = self
+ addChild(pageController)
+ view.addSubview(tabStrip)
+ view.addSubview(pageController.view)
+ pageController.didMove(toParent: self)
- stackView.axis = .vertical
- stackView.spacing = 14
- scrollView.keyboardDismissMode = .onDrag
- scrollView.alwaysBounceVertical = true
+ pages = ScenicQueueSettingsTab.allCases.map { ScenicQueueSettingsPageController(tab: $0) }
+ buildBasicPage(pages[0])
+ buildNotifyPage(pages[1])
+ buildVoicePage(pages[2])
+ pageController.setViewControllers([pages[0]], direction: .forward, animated: false)
saveBar.backgroundColor = .white
+ saveBar.layer.shadowColor = UIColor.black.cgColor
+ saveBar.layer.shadowOpacity = 0.08
+ saveBar.layer.shadowRadius = 6
+ saveBar.layer.shadowOffset = CGSize(width: 0, height: -2)
saveButton.setTitle("保存", for: .normal)
saveButton.setTitleColor(.white, for: .normal)
saveButton.titleLabel?.font = ScenicQueueTokens.settingsSaveButtonFont
saveButton.backgroundColor = ScenicQueueTokens.bannerBlue
saveButton.layer.cornerRadius = ScenicQueueTokens.bottomSaveButtonRadius
- saveButton.clipsToBounds = true
-
- view.addSubview(segmentedControl)
- view.addSubview(scrollView)
- scrollView.addSubview(stackView)
- view.addSubview(saveBar)
saveBar.addSubview(saveButton)
- rebuildForm()
+ view.addSubview(saveBar)
+ applyState()
}
override func setupConstraints() {
- segmentedControl.snp.makeConstraints { make in
- make.top.equalTo(view.safeAreaLayoutGuide).offset(8)
- make.leading.trailing.equalToSuperview().inset(16)
- make.height.equalTo(40)
- }
- saveBar.snp.makeConstraints { make in
- make.leading.trailing.bottom.equalToSuperview()
+ tabStrip.snp.makeConstraints { make in
+ make.top.equalTo(view.safeAreaLayoutGuide)
+ make.leading.trailing.equalToSuperview()
+ make.height.equalTo(48)
}
+ saveBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() }
saveButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(31)
- make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
make.height.equalTo(48)
+ make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
- scrollView.snp.makeConstraints { make in
- make.top.equalTo(segmentedControl.snp.bottom).offset(10)
+ pageController.view.snp.makeConstraints { make in
+ make.top.equalTo(tabStrip.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(saveBar.snp.top)
}
- stackView.snp.makeConstraints { make in
- make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 16, right: 16))
- make.width.equalToSuperview().offset(-32)
- }
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ loadPunchSpotMenuIfNeeded()
}
override func bindActions() {
- segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
+ tabStrip.onSelect = { [weak self] index in self?.selectPage(index, animated: true) }
saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside)
- viewModel.onStateChange = { [weak self] in
- Task { @MainActor in self?.applyState() }
- }
+ viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
@@ -114,316 +115,814 @@ final class ScenicQueueSettingsViewController: BaseViewController {
Task { @MainActor in self?.navigationController?.popViewController(animated: true) }
}
viewModel.onNavigateLog = { [weak self] in
- Task { @MainActor in self?.navigationController?.pushViewController(ScenicQueueSettingChangeLogViewController(), animated: true) }
+ Task { @MainActor in
+ self?.navigationController?.pushViewController(
+ ScenicQueueSettingChangeLogViewController(), animated: true)
+ }
}
}
+ private func selectPage(_ index: Int, animated: Bool) {
+ guard pages.indices.contains(index) else { return }
+ let current = viewModel.settingsTab.rawValue
+ let direction: UIPageViewController.NavigationDirection = index >= current ? .forward : .reverse
+ viewModel.setSettingsTab(ScenicQueueSettingsTab(rawValue: index) ?? .basic)
+ pageController.setViewControllers([pages[index]], direction: direction, animated: animated)
+ tabStrip.setSelectedIndex(index, animated: animated)
+ }
+
private func applyState() {
- segmentedControl.selectedSegmentIndex = viewModel.settingsTab.rawValue
- rebuildForm()
- }
-
- private func rebuildForm() {
- stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
- fieldRows.removeAll()
- switchRows.removeAll()
- switch viewModel.settingsTab {
- case .basic:
- buildBasicForm()
- case .notify:
- buildNotifyForm()
- case .voice:
- buildVoiceForm()
+ let selected = viewModel.settingsTab.rawValue
+ tabStrip.setSelectedIndex(selected, animated: true)
+ if pageController.viewControllers?.first !== pages[selected] {
+ pageController.setViewControllers([pages[selected]], direction: .forward, animated: false)
}
+ selectedPunchLabel.text = viewModel.selectedPunchSpotDisplayText
+ configurePunchSpotMenu()
+ startTimeButton.setTitle(viewModel.businessStartTime, for: .normal)
+ endTimeButton.setTitle(viewModel.businessEndTime, for: .normal)
+ updateField(
+ "queueDistanceMeter",
+ ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: viewModel.queueDistanceMeter))
+ updateField("shootMinute", "\(viewModel.shootMinute)")
+ updateField("shootSecond", "\(viewModel.shootSecond)")
+ updateField("queueTakeLimit", "\(viewModel.queueTakeLimit)")
+ updateField("missCallRequeueOffset", "\(viewModel.missCallRequeueOffset)")
+ updateField("autoCallAheadCount", "\(viewModel.autoCallAheadCount)")
+ updateField("firstNoticeAhead", "\(viewModel.firstAheadCount)")
+ updateField("secondNoticeAhead", "\(viewModel.secondAheadCount)")
+ updateField("broadcastIntervalSec", "\(viewModel.broadcastIntervalSec)")
+ updateField("countdownReadableThresholdSec", "\(viewModel.countdownThresholdSec)")
+ setSwitch("businessOpen", viewModel.businessOpen)
+ setSwitch("showShoot", viewModel.showStartShootingButton)
+ setSwitch("quickCall", viewModel.quickCallButtonEnabled)
+ setSwitch("prepareCall", viewModel.prepareCallButtonEnabled)
+ setSwitch("firstSms", viewModel.firstSms)
+ setSwitch("firstPhone", viewModel.firstPhone)
+ setSwitch("secondSms", viewModel.secondSms)
+ setSwitch("secondPhone", viewModel.secondPhone)
+ if !customTextView.isFirstResponder { customTextView.text = viewModel.customTTSText }
+ customTextView.isEditable = !viewModel.isCustomTTSLooping
+ customPlayButton.setTitle(viewModel.isCustomTTSLooping ? "停止播放" : "试听", for: .normal)
+ rebuildPresetRows()
+ viewModel.highlightedInvalidFieldKeys.forEach { numericFields[$0]?.setInvalid(true) }
}
- private func buildBasicForm() {
- stackView.addArrangedSubview(tipCard())
- stackView.addArrangedSubview(actionCard(title: "打卡点", value: viewModel.selectedPunchSpotDisplayText, primary: "选择打卡点", secondary: "保存小程序码", primaryAction: #selector(punchSpotTapped), secondaryAction: #selector(saveQRTapped), invalid: viewModel.highlightedInvalidFieldKeys.contains("punchSpot")))
- addSwitchRow(key: "businessOpen", title: "营业状态", isOn: viewModel.businessOpen, action: #selector(businessOpenChanged(_:)))
- addTextRow(key: "businessStart", title: "营业开始时间", value: viewModel.businessStartTime, suffix: "HH:mm", keyboard: .numbersAndPunctuation, action: #selector(businessStartChanged(_:)))
- addTextRow(key: "businessEnd", title: "营业结束时间", value: viewModel.businessEndTime, suffix: "HH:mm", keyboard: .numbersAndPunctuation, action: #selector(businessEndChanged(_:)))
- addTextRow(key: "queueDistance", title: "允许排队距离", value: "\(viewModel.queueDistanceMeter)", suffix: "米", keyboard: .numberPad, action: #selector(queueDistanceChanged(_:)))
- addTextRow(key: "shootMinute", title: "拍摄时长(分钟)", value: "\(viewModel.shootMinute)", suffix: "分", keyboard: .numberPad, action: #selector(shootMinuteChanged(_:)))
- addTextRow(key: "shootSecond", title: "拍摄时长(秒)", value: "\(viewModel.shootSecond)", suffix: "秒", keyboard: .numberPad, action: #selector(shootSecondChanged(_:)))
- addTextRow(key: "queueTakeLimit", title: "单人取号次数", value: "\(viewModel.queueTakeLimit)", suffix: "次", keyboard: .numberPad, action: #selector(queueTakeLimitChanged(_:)))
- addTextRow(key: "missOffset", title: "过号重排顺延位数", value: "\(viewModel.missCallRequeueOffset)", suffix: "位", keyboard: .numberPad, action: #selector(missOffsetChanged(_:)))
- addSwitchRow(key: "showShoot", title: "展示开始拍摄按钮", isOn: viewModel.showStartShootingButton, action: #selector(showShootChanged(_:)))
- addTextRow(key: "autoCall", title: "自动提前叫号数量", value: "\(viewModel.autoCallAheadCount)", suffix: "人", keyboard: .numberPad, action: #selector(autoCallChanged(_:)))
- addSwitchRow(key: "quickCall", title: "底部快速叫号", isOn: viewModel.quickCallButtonEnabled, action: #selector(quickCallChanged(_:)))
- addSwitchRow(key: "prepareCall", title: "底部准备叫号", isOn: viewModel.prepareCallButtonEnabled, action: #selector(prepareCallChanged(_:)))
+ private func updateField(_ key: String, _ value: String) {
+ let field = numericFields[key]
+ field?.setInvalid(viewModel.highlightedInvalidFieldKeys.contains(key))
+ field?.setDisplayValue(value)
}
- private func buildNotifyForm() {
- addTextRow(key: "firstAhead", title: "第一次提前通知阈值", value: "\(viewModel.firstAheadCount)", suffix: "位", keyboard: .numberPad, action: #selector(firstAheadChanged(_:)))
- addSwitchRow(key: "firstSms", title: "第一次短信通知", isOn: viewModel.firstSms, action: #selector(firstSmsChanged(_:)))
- addSwitchRow(key: "firstPhone", title: "第一次电话通知", isOn: viewModel.firstPhone, action: #selector(firstPhoneChanged(_:)))
- addTextRow(key: "secondAhead", title: "第二次提前通知阈值", value: "\(viewModel.secondAheadCount)", suffix: "位", keyboard: .numberPad, action: #selector(secondAheadChanged(_:)))
- addSwitchRow(key: "secondSms", title: "第二次短信通知", isOn: viewModel.secondSms, action: #selector(secondSmsChanged(_:)))
- addSwitchRow(key: "secondPhone", title: "第二次电话通知", isOn: viewModel.secondPhone, action: #selector(secondPhoneChanged(_:)))
+ private func setSwitch(_ key: String, _ value: Bool) { switches[key]?.setValue(value) }
+
+ private func buildBasicPage(_ page: ScenicQueueSettingsPageController) {
+ page.stack.addArrangedSubview(tipCard())
+ page.stack.addArrangedSubview(punchSpotCard())
+ page.stack.addArrangedSubview(businessCard())
+ page.stack.addArrangedSubview(
+ sectionCard(title: "排队距离限制") {
+ let hint = self.hintLabel("顾客仅可在该距离范围内取号,0 表示不限制。合法范围 0~9999.99 千米。")
+ let field = self.numericField(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad)
+ return [hint, field]
+ })
+ page.stack.addArrangedSubview(
+ sectionCard(title: "预计拍摄时长") {
+ let minute = self.numericField(key: "shootMinute", unit: "分")
+ let second = self.numericField(key: "shootSecond", unit: "秒")
+ let row = UIStackView(arrangedSubviews: [minute, second])
+ row.axis = .horizontal
+ row.spacing = 12
+ row.distribution = .fillEqually
+ return [self.hintLabel("总时长必须大于 0;分钟 0~30,秒 0~59。"), row]
+ })
+ page.stack.addArrangedSubview(
+ sectionCard(title: "单人取号次数限制") {
+ [
+ self.hintLabel("0 表示不限制,最大 999999 次。"),
+ self.numericField(key: "queueTakeLimit", unit: "次"),
+ ]
+ })
+ page.stack.addArrangedSubview(
+ sectionCard(title: "过号重新排队") {
+ [
+ self.hintLabel("设置过号重新排队时相对队首顺延的位数,范围 1~9999。"),
+ self.numericField(key: "missCallRequeueOffset", unit: "位"),
+ ]
+ })
+ page.stack.addArrangedSubview(
+ sectionCard(title: "拍摄与叫号配置", info: true) {
+ let shoot = self.switchRow(
+ key: "showShoot", title: "显示开始拍摄按钮", action: #selector(self.showShootChanged(_:)))
+ let auto = self.numericField(key: "autoCallAheadCount", unit: "人")
+ return [shoot, self.divider(), self.hintLabel("开始拍摄后自动通知后续 0~5 个排队号。"), auto]
+ })
+ page.stack.addArrangedSubview(
+ sectionCard(title: "底部叫号按钮") {
+ [
+ self.switchRow(
+ key: "quickCall", title: "显示快速叫号", action: #selector(self.quickCallChanged(_:))),
+ self.divider(),
+ self.switchRow(
+ key: "prepareCall", title: "显示准备叫号", action: #selector(self.prepareCallChanged(_:))),
+ ]
+ })
}
- private func buildVoiceForm() {
- addTextRow(key: "broadcastInterval", title: "播报间隔时间", value: "\(viewModel.broadcastIntervalSec)", suffix: "秒", keyboard: .numberPad, action: #selector(broadcastChanged(_:)))
- addTextRow(key: "countdown", title: "进入读秒倒计时阈值", value: "\(viewModel.countdownThresholdSec)", suffix: "秒", keyboard: .numberPad, action: #selector(countdownChanged(_:)))
- addSwitchRow(key: "offlineTTS", title: "离线语音合成", isOn: viewModel.useOfflineTTS, action: #selector(offlineTTSChanged(_:)))
- stackView.addArrangedSubview(actionCard(title: "蓝牙音响测试", value: "请确认外放音量是否合适", primary: "播放测试音", secondary: nil, primaryAction: #selector(testSoundTapped), secondaryAction: nil))
- addTextRow(key: "customTTS", title: "自定义播报文案", value: viewModel.customTTSText, suffix: "", keyboard: .default, action: #selector(customTTSChanged(_:)))
- stackView.addArrangedSubview(actionCard(title: "播报文案", value: "最多保存5个语音", primary: "播放文本", secondary: "保存到预设", primaryAction: #selector(playCustomTapped), secondaryAction: #selector(savePresetTapped)))
- for (index, phrase) in viewModel.presetVoices.enumerated() {
- stackView.addArrangedSubview(presetVoiceCard(index: index, phrase: phrase))
- }
+ private func buildNotifyPage(_ page: ScenicQueueSettingsPageController) {
+ page.stack.addArrangedSubview(
+ sectionCard(title: "提前通知规则", info: true) {
+ [
+ self.sectionLabel("第一次提前通知"),
+ self.hintLabel("前方人数达到阈值时触发,范围 0~50 位。"),
+ self.numericField(key: "firstNoticeAhead", unit: "位"),
+ self.switchRow(
+ key: "firstSms", title: "短信通知", action: #selector(self.firstSmsChanged(_:))),
+ self.switchRow(
+ key: "firstPhone", title: "电话通知(自动语音)", action: #selector(self.firstPhoneChanged(_:))),
+ self.divider(), self.sectionLabel("第二次提前通知"),
+ self.hintLabel("可设置更接近队首的二次提醒,范围 0~50 位。"),
+ self.numericField(key: "secondNoticeAhead", unit: "位"),
+ self.switchRow(
+ key: "secondSms", title: "短信通知", action: #selector(self.secondSmsChanged(_:))),
+ self.switchRow(
+ key: "secondPhone", title: "电话通知(自动语音)", action: #selector(self.secondPhoneChanged(_:))),
+ ]
+ })
}
- private func addTextRow(key: String, title: String, value: String, suffix: String, keyboard: UIKeyboardType, action: Selector) {
- let row = ScenicQueueSettingFieldRow(title: title, value: value, suffix: suffix, keyboard: keyboard)
- row.textField.addTarget(self, action: action, for: .editingChanged)
- fieldRows[key] = row
- stackView.addArrangedSubview(row)
- }
-
- private func addSwitchRow(key: String, title: String, isOn: Bool, action: Selector) {
- let row = ScenicQueueSwitchRow(title: title, isOn: isOn)
- row.toggle.addTarget(self, action: action, for: .valueChanged)
- switchRows[key] = row
- stackView.addArrangedSubview(row)
+ private func buildVoicePage(_ page: ScenicQueueSettingsPageController) {
+ page.stack.addArrangedSubview(
+ sectionCard(title: "倒计时播报配置", info: true) {
+ let test = self.primaryButton("播放测试声音", action: #selector(self.testSoundTapped))
+ return [
+ self.sectionLabel("播报间隔时间"), self.hintLabel("从开拍起按间隔播报剩余时间,合法范围 40~60 秒。"),
+ self.numericField(key: "broadcastIntervalSec", unit: "秒"),
+ self.sectionLabel("进入读秒倒计时阈值"), self.hintLabel("到达阈值后逐秒播报,合法范围 10~30 秒。"),
+ self.numericField(key: "countdownReadableThresholdSec", unit: "秒"),
+ self.sectionLabel("测试蓝牙音响"), test,
+ ]
+ })
+ page.stack.addArrangedSubview(customTTSCard())
+ page.stack.addArrangedSubview(presetVoicesCard())
}
private func tipCard() -> UIView {
let card = ScenicQueueSettingCard()
card.backgroundColor = ScenicQueueTokens.bannerBlue
- let title = UILabel()
- title.text = "排队基础参数"
+ card.layer.borderWidth = 0
+ let title = sectionLabel("温馨提示")
title.textColor = .white
- title.font = ScenicQueueTokens.settingsSectionTitleFont
- let body = UILabel()
- body.text = "选择打卡点后保存配置,顾客即可扫码取号排队。"
- body.textColor = UIColor.white.withAlphaComponent(0.86)
- body.font = ScenicQueueTokens.settingsHintFont
- body.numberOfLines = 0
- let stack = UIStackView(arrangedSubviews: [title, body])
- stack.axis = .vertical
- stack.spacing = 6
+ let body = hintLabel("配置信息修改后,请及时测试叫号设备状态")
+ body.textColor = UIColor.white.withAlphaComponent(0.92)
+ let stack = verticalStack([title, body], spacing: 6)
card.addSubview(stack)
- stack.snp.makeConstraints { make in
- make.edges.equalToSuperview().inset(14)
+ stack.snp.makeConstraints {
+ $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 20, left: 16, bottom: 20, right: 16))
}
return card
}
- private func actionCard(title: String, value: String, primary: String, secondary: String?, primaryAction: Selector, secondaryAction: Selector?, invalid: Bool = false) -> UIView {
+ private func punchSpotCard() -> UIView {
+ sectionCard(title: "打卡点信息") {
+ let label = self.hintLabel("选择打卡点")
+ let selector = self.punchSpotButton
+ selector.backgroundColor = .white
+ selector.layer.cornerRadius = ScenicQueueTokens.radiusInput
+ selector.layer.borderWidth = 1
+ selector.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor
+ selector.showsMenuAsPrimaryAction = true
+ self.selectedPunchLabel.font = ScenicQueueTokens.settingsRowTitleFont
+ self.selectedPunchLabel.textColor = AppColor.textPrimary
+ let arrow = UILabel()
+ arrow.text = "▼"
+ arrow.font = ScenicQueueTokens.settingsHintFont
+ arrow.textColor = AppColor.textTertiary
+ selector.addSubview(self.selectedPunchLabel)
+ selector.addSubview(arrow)
+ self.selectedPunchLabel.snp.makeConstraints { make in
+ make.leading.equalToSuperview().offset(12)
+ make.centerY.equalToSuperview()
+ make.trailing.lessThanOrEqualTo(arrow.snp.leading).offset(-8)
+ }
+ arrow.snp.makeConstraints { make in
+ make.trailing.equalToSuperview().offset(-12)
+ make.centerY.equalToSuperview()
+ }
+ selector.snp.makeConstraints { $0.height.equalTo(52) }
+ let qrLabel = self.sectionLabel("点击保存打卡点排队二维码")
+ let qr = self.compactPrimaryButton("保存二维码", action: #selector(self.saveQRTapped))
+ let qrRow = UIStackView(arrangedSubviews: [qrLabel, qr])
+ qrRow.axis = .horizontal
+ qrRow.alignment = .center
+ qrRow.spacing = 10
+ return [label, selector, qrRow]
+ }
+ }
+
+ private func businessCard() -> UIView {
+ sectionCard(title: "排队状态与时间", info: true) {
+ let status = self.switchRow(
+ key: "businessOpen", title: "当前排队状态", action: #selector(self.businessOpenChanged(_:)))
+ self.startTimeButton = self.timeButton(action: #selector(self.startTimeTapped))
+ self.endTimeButton = self.timeButton(action: #selector(self.endTimeTapped))
+ let times = UIStackView(arrangedSubviews: [
+ self.labeledControl("营业开始时间", self.startTimeButton),
+ self.labeledControl("营业结束时间", self.endTimeButton),
+ ])
+ times.axis = .horizontal
+ times.distribution = .fillEqually
+ times.spacing = 12
+ return [status, self.hintLabel("开启后顾客可在营业时间内扫码取号;关闭后暂停排队。"), self.divider(), times]
+ }
+ }
+
+ private func customTTSCard() -> UIView {
+ sectionCard(title: "自定义文本语音播报") {
+ self.customTextView.font = ScenicQueueTokens.settingsRowTitleFont
+ self.customTextView.textColor = AppColor.textPrimary
+ self.customTextView.layer.cornerRadius = ScenicQueueTokens.radiusInput
+ self.customTextView.layer.borderWidth = 1
+ self.customTextView.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor
+ self.customTextView.delegate = self
+ self.customTextView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8)
+ self.customTextView.snp.makeConstraints { $0.height.equalTo(104) }
+ self.customPlayButton = self.outlinedButton("试听", action: #selector(self.customPlayTapped))
+ let save = self.primaryButton("保存", action: #selector(self.savePresetTapped))
+ let row = UIStackView(arrangedSubviews: [self.customPlayButton, save])
+ row.axis = .horizontal
+ row.distribution = .fillEqually
+ row.spacing = 12
+ return [self.customTextView, row]
+ }
+ }
+
+ private func presetVoicesCard() -> UIView {
let card = ScenicQueueSettingCard()
- card.layer.borderColor = invalid ? AppColor.danger.cgColor : ScenicQueueTokens.cardOutline.cgColor
- let titleLabel = UILabel()
- titleLabel.text = title
- titleLabel.font = ScenicQueueTokens.settingsFieldLabelFont
- titleLabel.textColor = AppColor.textPrimary
- let valueLabel = UILabel()
- valueLabel.text = value
- valueLabel.font = ScenicQueueTokens.settingsHintFont
- valueLabel.textColor = AppColor.textSecondary
- valueLabel.numberOfLines = 0
- let primaryButton = UIButton(type: .system)
- primaryButton.setTitle(primary, for: .normal)
- primaryButton.titleLabel?.font = ScenicQueueTokens.buttonSecondaryFont
- primaryButton.addTarget(self, action: primaryAction, for: .touchUpInside)
- let buttons = UIStackView(arrangedSubviews: [primaryButton])
- buttons.axis = .horizontal
- buttons.spacing = 12
- if let secondary, let secondaryAction {
- let secondaryButton = UIButton(type: .system)
- secondaryButton.setTitle(secondary, for: .normal)
- secondaryButton.titleLabel?.font = ScenicQueueTokens.buttonSecondaryFont
- secondaryButton.addTarget(self, action: secondaryAction, for: .touchUpInside)
- buttons.addArrangedSubview(secondaryButton)
- }
- let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel, buttons])
- stack.axis = .vertical
- stack.spacing = 8
+ presetSummaryLabel.font = ScenicQueueTokens.settingsSectionTitleFont
+ presetSummaryLabel.textColor = AppColor.textPrimary
+ presetStack.axis = .vertical
+ presetStack.spacing = 10
+ let hint = hintLabel("左滑显示删除;点左侧图标循环播报该条。底部「保存」提交全部排队参数。")
+ let stack = verticalStack([presetSummaryLabel, presetStack, hint], spacing: 14)
card.addSubview(stack)
- stack.snp.makeConstraints { make in
- make.edges.equalToSuperview().inset(14)
- }
+ stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
return card
}
- private func presetVoiceCard(index: Int, phrase: String) -> UIView {
+ private func rebuildPresetRows() {
+ presetSummaryLabel.text = "已保存的语音(\(viewModel.presetVoices.count)/5)"
+ presetStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
+ guard !viewModel.presetVoices.isEmpty else {
+ presetStack.addArrangedSubview(hintLabel("暂无预设文案,输入后点击「保存」添加到本地列表(最多 5 条)。"))
+ return
+ }
+ for (index, phrase) in viewModel.presetVoices.enumerated() {
+ let playing =
+ viewModel.isCustomTTSLooping
+ && viewModel.ttsLoopAnchorText?.trimmingCharacters(in: .whitespacesAndNewlines)
+ == phrase.trimmingCharacters(in: .whitespacesAndNewlines)
+ let row = ScenicQueuePresetVoiceRow(text: phrase, playing: playing)
+ row.tag = index
+ row.onPlay = { [weak self] in
+ guard let self else { return }
+ if playing {
+ self.viewModel.toggleCustomTTSPlayback()
+ } else {
+ self.viewModel.startPresetVoiceLoop(phrase)
+ }
+ }
+ row.onDelete = { [weak self] in self?.viewModel.deletePresetVoice(at: index) }
+ presetStack.addArrangedSubview(row)
+ }
+ }
+
+ private func sectionCard(title: String, info: Bool = false, content: () -> [UIView]) -> UIView {
let card = ScenicQueueSettingCard()
+ let heading = sectionLabel(title)
+ if info { heading.text = "\(title) ⓘ" }
+ let stack = verticalStack([heading] + content(), spacing: 12)
+ card.addSubview(stack)
+ stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
+ return card
+ }
+
+ private func numericField(key: String, unit: String, keyboard: UIKeyboardType = .numberPad)
+ -> ScenicQueueNumericFieldView
+ {
+ let field = ScenicQueueNumericFieldView(key: key, unit: unit, keyboard: keyboard)
+ field.delegate = self
+ numericFields[key] = field
+ return field
+ }
+
+ private func switchRow(key: String, title: String, action: Selector) -> ScenicQueueSwitchRow {
+ let row = ScenicQueueSwitchRow(title: title)
+ row.toggle.addTarget(self, action: action, for: .valueChanged)
+ switches[key] = row
+ return row
+ }
+
+ private func verticalStack(_ views: [UIView], spacing: CGFloat) -> UIStackView {
+ let stack = UIStackView(arrangedSubviews: views)
+ stack.axis = .vertical
+ stack.spacing = spacing
+ return stack
+ }
+
+ private func sectionLabel(_ text: String) -> UILabel {
let label = UILabel()
- label.text = phrase
- label.font = ScenicQueueTokens.settingsRowTitleFont
+ label.text = text
+ label.font = ScenicQueueTokens.settingsSectionTitleFont
label.textColor = AppColor.textPrimary
label.numberOfLines = 0
- let delete = UIButton(type: .system)
- delete.setImage(UIImage(systemName: "trash"), for: .normal)
- delete.tintColor = AppColor.danger
- delete.tag = index
- delete.addTarget(self, action: #selector(deletePresetTapped(_:)), for: .touchUpInside)
- card.addSubview(label)
- card.addSubview(delete)
- label.snp.makeConstraints { make in
- make.leading.top.bottom.equalToSuperview().inset(14)
- make.trailing.equalTo(delete.snp.leading).offset(-12)
- }
- delete.snp.makeConstraints { make in
- make.trailing.equalToSuperview().offset(-14)
- make.centerY.equalToSuperview()
- make.size.equalTo(44)
- }
- return card
+ return label
}
- @objc private func tabChanged() {
- viewModel.setSettingsTab(ScenicQueueSettingsTab(rawValue: segmentedControl.selectedSegmentIndex) ?? .basic)
+ private func hintLabel(_ text: String) -> UILabel {
+ let label = UILabel()
+ label.text = text
+ label.font = ScenicQueueTokens.settingsHintFont
+ label.textColor = AppColor.textTertiary
+ label.numberOfLines = 0
+ return label
+ }
+
+ private func divider() -> UIView {
+ let view = UIView()
+ view.backgroundColor = ScenicQueueTokens.cardOutline
+ view.snp.makeConstraints { $0.height.equalTo(1 / UIScreen.main.scale) }
+ return view
+ }
+
+ private func primaryButton(_ title: String, action: Selector) -> UIButton {
+ let button = compactPrimaryButton(title, action: action)
+ button.snp.makeConstraints { $0.height.equalTo(44) }
+ return button
+ }
+
+ private func compactPrimaryButton(_ title: String, action: Selector) -> UIButton {
+ let button = UIButton(type: .system)
+ button.setTitle(title, for: .normal)
+ button.setTitleColor(.white, for: .normal)
+ button.titleLabel?.font = ScenicQueueTokens.settingsRowTitleFont
+ button.backgroundColor = ScenicQueueTokens.bannerBlue
+ button.layer.cornerRadius = ScenicQueueTokens.radiusInput
+ button.addTarget(self, action: action, for: .touchUpInside)
+ button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
+ return button
+ }
+
+ private func outlinedButton(_ title: String, action: Selector) -> UIButton {
+ let button = UIButton(type: .system)
+ button.setTitle(title, for: .normal)
+ button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal)
+ button.titleLabel?.font = ScenicQueueTokens.settingsRowTitleFont
+ button.layer.cornerRadius = ScenicQueueTokens.radiusInput
+ button.layer.borderWidth = 1
+ button.layer.borderColor = ScenicQueueTokens.bannerBlue.cgColor
+ button.addTarget(self, action: action, for: .touchUpInside)
+ button.snp.makeConstraints { $0.height.equalTo(44) }
+ return button
+ }
+
+ private func timeButton(action: Selector) -> UIButton {
+ let button = outlinedButton("--:--", action: action)
+ button.setTitleColor(AppColor.textPrimary, for: .normal)
+ button.layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor
+ return button
+ }
+
+ private func labeledControl(_ title: String, _ control: UIView) -> UIView {
+ let label = hintLabel(title)
+ let stack = verticalStack([label, control], spacing: 8)
+ return stack
+ }
+
+ private func loadPunchSpotMenuIfNeeded() {
+ guard !punchSpotOptionsLoaded, !punchSpotOptionsLoading else { return }
+ punchSpotOptionsLoading = true
+ configurePunchSpotMenu()
+ Task {
+ await viewModel.loadPunchSpotOptions(api: api)
+ punchSpotOptionsLoading = false
+ punchSpotOptionsLoaded = true
+ configurePunchSpotMenu()
+ }
+ }
+
+ private func configurePunchSpotMenu() {
+ if punchSpotOptionsLoading {
+ punchSpotButton.menu = UIMenu(children: [
+ UIAction(title: "正在加载打卡点…", attributes: .disabled) { _ in }
+ ])
+ return
+ }
+ let actions = viewModel.punchSpotOptions.map { option in
+ UIAction(
+ title: option.label,
+ state: option.id == viewModel.selectedPunchSpotId ? .on : .off
+ ) { [weak self] _ in
+ guard let self else { return }
+ Task { await self.viewModel.setPunchSpot(option, api: self.api) }
+ }
+ }
+ punchSpotButton.menu = UIMenu(
+ options: .singleSelection,
+ children: actions.isEmpty
+ ? [UIAction(title: "暂无可选打卡点", attributes: .disabled) { _ in }]
+ : actions
+ )
+ }
+
+ private func presentTimePicker(initial: String, setter: @escaping (String) -> Void) {
+ present(
+ ScenicQueueTimePickerViewController(initialTime: initial, onConfirm: setter), animated: true)
}
@objc private func logTapped() { viewModel.openSettingChangeLog() }
-
@objc private func saveTapped() {
view.endEditing(true)
Task { await viewModel.save(api: api) }
}
-
- @objc private func punchSpotTapped() {
- Task {
- await viewModel.loadPunchSpotOptions(api: api)
- await MainActor.run { presentPunchSpotPicker() }
- }
- }
-
- private func presentPunchSpotPicker() {
- let alert = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet)
- viewModel.punchSpotOptions.forEach { option in
- alert.addAction(UIAlertAction(title: option.label, style: .default) { [weak self] _ in
- guard let self else { return }
- Task { await self.viewModel.setPunchSpot(option, api: self.api) }
- })
- }
- alert.addAction(UIAlertAction(title: "取消", style: .cancel))
- present(alert, animated: true)
- }
-
@objc private func saveQRTapped() { Task { await viewModel.saveShootQueueQRCode(api: api) } }
+ @objc private func startTimeTapped() {
+ presentTimePicker(initial: viewModel.businessStartTime, setter: viewModel.setBusinessStartTime)
+ }
+ @objc private func endTimeTapped() {
+ presentTimePicker(initial: viewModel.businessEndTime, setter: viewModel.setBusinessEndTime)
+ }
@objc private func testSoundTapped() { viewModel.playBluetoothTestSound() }
- @objc private func playCustomTapped() { viewModel.toggleCustomTTSPlayback() }
- @objc private func savePresetTapped() { viewModel.saveCurrentTextAsPresetVoiceLocal() }
- @objc private func deletePresetTapped(_ sender: UIButton) { viewModel.deletePresetVoice(at: sender.tag) }
-
+ @objc private func customPlayTapped() { viewModel.toggleCustomTTSPlayback() }
+ @objc private func savePresetTapped() {
+ viewModel.setCustomTTSText(customTextView.text ?? "")
+ viewModel.saveCurrentTextAsPresetVoiceLocal()
+ }
@objc private func businessOpenChanged(_ sender: UISwitch) { viewModel.toggleBusinessOpen() }
- @objc private func showShootChanged(_ sender: UISwitch) { viewModel.toggleShowStartShootingButton() }
- @objc private func quickCallChanged(_ sender: UISwitch) { viewModel.toggleQuickCallButtonEnabled() }
- @objc private func prepareCallChanged(_ sender: UISwitch) { viewModel.togglePrepareCallButtonEnabled() }
+ @objc private func showShootChanged(_ sender: UISwitch) {
+ viewModel.toggleShowStartShootingButton()
+ }
+ @objc private func quickCallChanged(_ sender: UISwitch) {
+ viewModel.toggleQuickCallButtonEnabled()
+ }
+ @objc private func prepareCallChanged(_ sender: UISwitch) {
+ viewModel.togglePrepareCallButtonEnabled()
+ }
@objc private func firstSmsChanged(_ sender: UISwitch) { viewModel.toggleFirstSms() }
@objc private func firstPhoneChanged(_ sender: UISwitch) { viewModel.toggleFirstPhone() }
@objc private func secondSmsChanged(_ sender: UISwitch) { viewModel.toggleSecondSms() }
@objc private func secondPhoneChanged(_ sender: UISwitch) { viewModel.toggleSecondPhone() }
-
- @objc private func businessStartChanged(_ sender: UITextField) { viewModel.setBusinessStartTime(sender.text ?? "") }
- @objc private func businessEndChanged(_ sender: UITextField) { viewModel.setBusinessEndTime(sender.text ?? "") }
- @objc private func queueDistanceChanged(_ sender: UITextField) { viewModel.setQueueDistanceMeter(Int(sender.text ?? "") ?? 0) }
- @objc private func shootMinuteChanged(_ sender: UITextField) { viewModel.setShootMinute(Int(sender.text ?? "") ?? 0) }
- @objc private func shootSecondChanged(_ sender: UITextField) { viewModel.setShootSecond(Int(sender.text ?? "") ?? 0) }
- @objc private func queueTakeLimitChanged(_ sender: UITextField) { viewModel.setQueueTakeLimit(Int(sender.text ?? "") ?? 0) }
- @objc private func missOffsetChanged(_ sender: UITextField) { viewModel.setMissCallRequeueOffset(Int(sender.text ?? "") ?? 1) }
- @objc private func autoCallChanged(_ sender: UITextField) { viewModel.setAutoCallAheadCount(Int(sender.text ?? "") ?? 0) }
- @objc private func firstAheadChanged(_ sender: UITextField) { viewModel.setFirstAheadCount(Int(sender.text ?? "") ?? 0) }
- @objc private func secondAheadChanged(_ sender: UITextField) { viewModel.setSecondAheadCount(Int(sender.text ?? "") ?? 0) }
- @objc private func broadcastChanged(_ sender: UITextField) { viewModel.setBroadcastIntervalSec(Int(sender.text ?? "") ?? 50) }
- @objc private func countdownChanged(_ sender: UITextField) { viewModel.setCountdownThresholdSec(Int(sender.text ?? "") ?? 15) }
- @objc private func offlineTTSChanged(_ sender: UISwitch) { viewModel.setUseOfflineTTS(sender.isOn) }
- @objc private func customTTSChanged(_ sender: UITextField) { viewModel.setCustomTTSText(sender.text ?? "") }
}
-/// 设置页卡片容器。
-class ScenicQueueSettingCard: UIView {
+extension ScenicQueueSettingsViewController: UIPageViewControllerDataSource,
+ UIPageViewControllerDelegate
+{
+ func pageViewController(
+ _ pageViewController: UIPageViewController,
+ viewControllerBefore viewController: UIViewController
+ ) -> UIViewController? {
+ guard let page = viewController as? ScenicQueueSettingsPageController,
+ page.settingsTab.rawValue > 0
+ else { return nil }
+ return pages[page.settingsTab.rawValue - 1]
+ }
+
+ func pageViewController(
+ _ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController
+ ) -> UIViewController? {
+ guard let page = viewController as? ScenicQueueSettingsPageController,
+ page.settingsTab.rawValue < pages.count - 1
+ else { return nil }
+ return pages[page.settingsTab.rawValue + 1]
+ }
+
+ func pageViewController(
+ _ pageViewController: UIPageViewController, didFinishAnimating finished: Bool,
+ previousViewControllers: [UIViewController], transitionCompleted completed: Bool
+ ) {
+ guard completed,
+ let page = pageViewController.viewControllers?.first as? ScenicQueueSettingsPageController
+ else { return }
+ viewModel.setSettingsTab(page.settingsTab)
+ tabStrip.setSelectedIndex(page.settingsTab.rawValue, animated: true)
+ }
+}
+
+extension ScenicQueueSettingsViewController: ScenicQueueNumericFieldViewDelegate {
+ func numericFieldView(_ field: ScenicQueueNumericFieldView, commit raw: String, key: String)
+ -> Bool
+ {
+ func integer(_ range: ClosedRange, setter: (Int) -> Void) -> Bool {
+ guard let value = Int(raw), range.contains(value) else { return false }
+ setter(value)
+ return true
+ }
+ let valid: Bool
+ switch key {
+ case "queueDistanceMeter":
+ if let meters = ScenicQueueSettingsViewModel.meters(fromKilometersText: raw) {
+ viewModel.setQueueDistanceMeter(meters)
+ valid = true
+ } else {
+ valid = false
+ }
+ case "shootMinute": valid = integer(0...30, setter: viewModel.setShootMinute)
+ case "shootSecond": valid = integer(0...59, setter: viewModel.setShootSecond)
+ case "queueTakeLimit": valid = integer(0...999_999, setter: viewModel.setQueueTakeLimit)
+ case "missCallRequeueOffset":
+ valid = integer(1...9_999, setter: viewModel.setMissCallRequeueOffset)
+ case "autoCallAheadCount": valid = integer(0...5, setter: viewModel.setAutoCallAheadCount)
+ case "firstNoticeAhead": valid = integer(0...50, setter: viewModel.setFirstAheadCount)
+ case "secondNoticeAhead": valid = integer(0...50, setter: viewModel.setSecondAheadCount)
+ case "broadcastIntervalSec": valid = integer(40...60, setter: viewModel.setBroadcastIntervalSec)
+ case "countdownReadableThresholdSec":
+ valid = integer(10...30, setter: viewModel.setCountdownThresholdSec)
+ default: valid = false
+ }
+ viewModel.reportOutOfRangeNumericPending(fieldKey: key, pending: !valid)
+ if !valid { showToast("输入值超出允许范围") }
+ return valid
+ }
+}
+
+extension ScenicQueueSettingsViewController: UITextViewDelegate {
+ func textViewDidChange(_ textView: UITextView) {
+ if textView.text.count > 255 { textView.text = String(textView.text.prefix(255)) }
+ viewModel.setCustomTTSText(textView.text)
+ }
+}
+
+/// 设置页单个分页的持久化滚动容器。
+final class ScenicQueueSettingsPageController: UIViewController {
+ let settingsTab: ScenicQueueSettingsTab
+ let stack = UIStackView()
+ private let scrollView = UIScrollView()
+
+ init(tab: ScenicQueueSettingsTab) {
+ self.settingsTab = tab
+ super.init(nibName: nil, bundle: nil)
+ }
+ @available(*, unavailable) required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = ScenicQueueTokens.pageBackground
+ scrollView.keyboardDismissMode = .onDrag
+ scrollView.alwaysBounceVertical = true
+ stack.axis = .vertical
+ stack.spacing = 14
+ view.addSubview(scrollView)
+ scrollView.addSubview(stack)
+ scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
+ stack.snp.makeConstraints { make in
+ make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 16, right: 16))
+ make.width.equalToSuperview().offset(-32)
+ }
+ }
+}
+
+/// 设置页白色描边卡片。
+final class ScenicQueueSettingCard: UIView {
init() {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = ScenicQueueTokens.radiusCard
layer.borderWidth = 1
layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
- clipsToBounds = true
}
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
+ @available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
-/// 设置页文本字段行。
-final class ScenicQueueSettingFieldRow: ScenicQueueSettingCard {
- let textField = UITextField()
+/// 数字输入视图提交协议。
+protocol ScenicQueueNumericFieldViewDelegate: AnyObject {
+ func numericFieldView(_ field: ScenicQueueNumericFieldView, commit raw: String, key: String)
+ -> Bool
+}
- init(title: String, value: String, suffix: String, keyboard: UIKeyboardType) {
- super.init()
- let label = UILabel()
- label.text = title
- label.font = ScenicQueueTokens.settingsFieldLabelFont
- label.textColor = AppColor.textPrimary
- textField.text = value
+/// Android OutlinedTextField 样式的数字与单位输入视图。
+final class ScenicQueueNumericFieldView: UIView, UITextFieldDelegate {
+ let key: String
+ weak var delegate: ScenicQueueNumericFieldViewDelegate?
+ private let textField = UITextField()
+ private var invalid = false
+
+ init(key: String, unit: String, keyboard: UIKeyboardType) {
+ self.key = key
+ super.init(frame: .zero)
+ let unitLabel = UILabel()
+ unitLabel.text = unit
+ unitLabel.font = ScenicQueueTokens.settingsHintFont
+ unitLabel.textColor = AppColor.textSecondary
+ unitLabel.textAlignment = .right
+ unitLabel.setContentHuggingPriority(.required, for: .horizontal)
+ unitLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
textField.keyboardType = keyboard
- textField.textAlignment = .right
textField.font = ScenicQueueTokens.settingsRowTitleFont
textField.textColor = AppColor.textPrimary
- let suffixLabel = UILabel()
- suffixLabel.text = suffix
- suffixLabel.font = ScenicQueueTokens.settingsHintFont
- suffixLabel.textColor = AppColor.textSecondary
- addSubview(label)
+ textField.delegate = self
+ layer.cornerRadius = ScenicQueueTokens.radiusInput
+ layer.borderWidth = 1
+ layer.borderColor = ScenicQueueTokens.fieldBorderMuted.cgColor
addSubview(textField)
- addSubview(suffixLabel)
- label.snp.makeConstraints { make in
- make.leading.equalToSuperview().offset(14)
- make.centerY.equalToSuperview()
- }
- suffixLabel.snp.makeConstraints { make in
- make.trailing.equalToSuperview().offset(-14)
+ addSubview(unitLabel)
+ let focusTap = UITapGestureRecognizer(target: self, action: #selector(focusInput))
+ focusTap.cancelsTouchesInView = false
+ addGestureRecognizer(focusTap)
+ unitLabel.snp.makeConstraints { make in
+ make.trailing.equalToSuperview().inset(8)
make.centerY.equalToSuperview()
}
textField.snp.makeConstraints { make in
- make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12)
- make.trailing.equalTo(suffixLabel.snp.leading).offset(-6)
- make.top.bottom.equalToSuperview().inset(8)
- make.height.equalTo(38)
- make.width.greaterThanOrEqualTo(90)
+ make.leading.equalToSuperview().offset(12)
+ make.trailing.equalTo(unitLabel.snp.leading).offset(-8)
+ make.top.bottom.equalToSuperview()
}
+ snp.makeConstraints { $0.height.equalTo(52) }
+ }
+ @available(*, unavailable) required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
}
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
+ func setDisplayValue(_ value: String) {
+ if !textField.isFirstResponder && !invalid { textField.text = value }
+ }
+ func setInvalid(_ value: Bool) {
+ invalid = value
+ layer.borderColor = (value ? AppColor.danger : ScenicQueueTokens.fieldBorderMuted).cgColor
+ }
+ @objc private func focusInput() { textField.becomeFirstResponder() }
+ func textFieldDidBeginEditing(_ textField: UITextField) {
+ layer.borderColor = ScenicQueueTokens.bannerBlue.cgColor
+ }
+ func textFieldDidEndEditing(_ textField: UITextField) {
+ let ok = delegate?.numericFieldView(self, commit: textField.text ?? "", key: key) ?? false
+ setInvalid(!ok)
}
}
-/// 设置页开关行。
-final class ScenicQueueSwitchRow: ScenicQueueSettingCard {
+/// 设置页标题与开关行。
+final class ScenicQueueSwitchRow: UIView {
let toggle = UISwitch()
-
- init(title: String, isOn: Bool) {
- super.init()
+ init(title: String) {
+ super.init(frame: .zero)
let label = UILabel()
label.text = title
- label.font = ScenicQueueTokens.settingsFieldLabelFont
+ label.font = ScenicQueueTokens.settingsRowTitleFont
label.textColor = AppColor.textPrimary
- toggle.isOn = isOn
+ label.numberOfLines = 0
toggle.onTintColor = ScenicQueueTokens.bannerBlue
addSubview(label)
addSubview(toggle)
label.snp.makeConstraints { make in
- make.leading.equalToSuperview().offset(14)
- make.centerY.equalToSuperview()
- make.top.bottom.equalToSuperview().inset(14)
- }
- toggle.snp.makeConstraints { make in
- make.trailing.equalToSuperview().offset(-14)
- make.centerY.equalToSuperview()
- make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12)
+ make.leading.top.bottom.equalToSuperview()
+ make.trailing.lessThanOrEqualTo(toggle.snp.leading).offset(-12)
}
+ toggle.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() }
+ snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
}
-
- @available(*, unavailable)
- required init?(coder: NSCoder) {
+ @available(*, unavailable) required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
+ func setValue(_ value: Bool) { toggle.setOn(value, animated: false) }
+}
+
+/// 已保存语音的播放与左滑删除行。
+final class ScenicQueuePresetVoiceRow: UIView {
+ var onPlay: (() -> Void)?
+ var onDelete: (() -> Void)?
+ private let content = UIView()
+ private let deleteButton = UIButton(type: .system)
+ private var revealed = false
+
+ init(text: String, playing: Bool) {
+ super.init(frame: .zero)
+ layer.cornerRadius = ScenicQueueTokens.radiusInput
+ clipsToBounds = true
+ deleteButton.setTitle("删除", for: .normal)
+ deleteButton.setTitleColor(.white, for: .normal)
+ deleteButton.backgroundColor = UIColor(hex: 0xE53935)
+ deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
+ content.backgroundColor = .white
+ content.layer.borderWidth = 1
+ content.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor
+ content.layer.cornerRadius = ScenicQueueTokens.radiusInput
+ let play = UIButton(type: .system)
+ play.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal)
+ play.tintColor = ScenicQueueTokens.bannerBlue
+ play.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
+ let label = UILabel()
+ label.text = text
+ label.font = ScenicQueueTokens.settingsRowTitleFont
+ label.textColor = AppColor.textPrimary
+ label.numberOfLines = 3
+ addSubview(deleteButton)
+ addSubview(content)
+ content.addSubview(play)
+ content.addSubview(label)
+ deleteButton.snp.makeConstraints { make in
+ make.trailing.top.bottom.equalToSuperview()
+ make.width.equalTo(80)
+ }
+ content.snp.makeConstraints { $0.edges.equalToSuperview() }
+ play.snp.makeConstraints { make in
+ make.leading.equalToSuperview().offset(4)
+ make.centerY.equalToSuperview()
+ make.size.equalTo(44)
+ }
+ label.snp.makeConstraints { make in
+ make.leading.equalTo(play.snp.trailing).offset(4)
+ make.trailing.equalToSuperview().offset(-12)
+ make.top.bottom.equalToSuperview().inset(12)
+ }
+ addGestureRecognizer(UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:))))
+ (gestureRecognizers?.last as? UISwipeGestureRecognizer)?.direction = .left
+ let right = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
+ right.direction = .right
+ addGestureRecognizer(right)
+ snp.makeConstraints { $0.height.greaterThanOrEqualTo(52) }
+ }
+ @available(*, unavailable) required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+ @objc private func playTapped() { onPlay?() }
+ @objc private func deleteTapped() { onDelete?() }
+ @objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
+ revealed = gesture.direction == .left
+ content.snp.updateConstraints { make in
+ make.leading.equalToSuperview().offset(revealed ? -80 : 0)
+ make.trailing.equalToSuperview().offset(revealed ? -80 : 0)
+ }
+ UIView.animate(withDuration: 0.2) { self.layoutIfNeeded() }
+ }
+}
+
+/// 营业时间的 Android 风格居中时间选择弹层。
+final class ScenicQueueTimePickerViewController: UIViewController {
+ private let onConfirm: (String) -> Void
+ private let picker = UIDatePicker()
+ init(initialTime: String, onConfirm: @escaping (String) -> Void) {
+ self.onConfirm = onConfirm
+ super.init(nibName: nil, bundle: nil)
+ modalPresentationStyle = .overFullScreen
+ modalTransitionStyle = .crossDissolve
+ let formatter = DateFormatter()
+ formatter.dateFormat = "HH:mm"
+ picker.date = formatter.date(from: initialTime) ?? Date()
+ }
+ @available(*, unavailable) required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.42)
+ let card = UIView()
+ card.backgroundColor = .white
+ card.layer.cornerRadius = ScenicQueueTokens.radiusDialog
+ picker.datePickerMode = .time
+ picker.preferredDatePickerStyle = .wheels
+ picker.locale = Locale(identifier: "zh_CN")
+ let cancel = button("取消", filled: false, action: #selector(cancelTapped))
+ let confirm = button("确认", filled: true, action: #selector(confirmTapped))
+ let row = UIStackView(arrangedSubviews: [cancel, confirm])
+ row.axis = .horizontal
+ row.spacing = 12
+ row.distribution = .fillEqually
+ view.addSubview(card)
+ card.addSubview(picker)
+ card.addSubview(row)
+ card.snp.makeConstraints { make in
+ make.center.equalToSuperview()
+ make.leading.trailing.equalToSuperview().inset(32)
+ }
+ picker.snp.makeConstraints { make in
+ make.top.leading.trailing.equalToSuperview()
+ make.height.equalTo(190)
+ }
+ row.snp.makeConstraints { make in
+ make.top.equalTo(picker.snp.bottom)
+ make.leading.trailing.bottom.equalToSuperview().inset(20)
+ make.height.equalTo(44)
+ }
+ }
+ private func button(_ title: String, filled: Bool, action: Selector) -> UIButton {
+ let b = UIButton(type: .system)
+ b.setTitle(title, for: .normal)
+ b.setTitleColor(filled ? .white : ScenicQueueTokens.bannerBlue, for: .normal)
+ b.backgroundColor =
+ filled ? ScenicQueueTokens.bannerBlue : ScenicQueueTokens.dialogCancelBackground
+ b.layer.cornerRadius = 8
+ b.addTarget(self, action: action, for: .touchUpInside)
+ return b
+ }
+ @objc private func cancelTapped() { dismiss(animated: true) }
+ @objc private func confirmTapped() {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "HH:mm"
+ let value = formatter.string(from: picker.date)
+ dismiss(animated: true) { self.onConfirm(value) }
+ }
}
diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift
index a4637a3..16bcd3f 100644
--- a/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift
+++ b/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift
@@ -15,12 +15,13 @@ final class ScenicQueueViewController: BaseViewController {
private let punchSpotLabel = UILabel()
private let statsBanner = ScenicQueueStatsBannerView()
private let contentContainer = UIView()
- private let segmentedControl = UISegmentedControl(items: ["当前排队", "过号列表"])
+ private let tabStrip = ScenicQueueTabStripView(titles: ["当前排队", "过号列表"])
private let tableView = UITableView(frame: .zero, style: .plain)
private let bottomBar = UIView()
private let prepareCallButton = UIButton(type: .system)
private let quickCallButton = UIButton(type: .system)
private let emptyLabel = UILabel()
+ private var dataSource: UITableViewDiffableDataSource!
private var selectedTab = 0
@@ -64,8 +65,8 @@ final class ScenicQueueViewController: BaseViewController {
view.backgroundColor = ScenicQueueTokens.pageBackground
punchSpotRow.backgroundColor = .white
- let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse"))
- locationIcon.tintColor = ScenicQueueTokens.bannerBlue
+ let locationIcon = UIImageView(image: UIImage(named: "queue_location"))
+ locationIcon.contentMode = .scaleAspectFit
punchSpotLabel.font = .systemFont(ofSize: 16)
punchSpotLabel.textColor = UIColor.black.withAlphaComponent(0.7)
punchSpotLabel.lineBreakMode = .byTruncatingTail
@@ -75,29 +76,21 @@ final class ScenicQueueViewController: BaseViewController {
contentContainer.backgroundColor = .white
contentContainer.layer.cornerRadius = ScenicQueueTokens.radiusContentSheetTop
contentContainer.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
- contentContainer.clipsToBounds = true
-
- segmentedControl.selectedSegmentIndex = 0
- segmentedControl.selectedSegmentTintColor = .white
- segmentedControl.setTitleTextAttributes([
- .font: ScenicQueueTokens.tabFont,
- .foregroundColor: AppColor.textSecondary,
- ], for: .normal)
- segmentedControl.setTitleTextAttributes([
- .font: ScenicQueueTokens.tabSelectedFont,
- .foregroundColor: ScenicQueueTokens.bannerBlue,
- ], for: .selected)
+ contentContainer.layer.shadowColor = UIColor.black.cgColor
+ contentContainer.layer.shadowOpacity = 0.08
+ contentContainer.layer.shadowRadius = 4
+ contentContainer.layer.shadowOffset = CGSize(width: 0, height: 2)
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 260
tableView.delegate = self
- tableView.dataSource = self
tableView.register(ScenicQueueTicketCell.self, forCellReuseIdentifier: ScenicQueueTicketCell.reuseIdentifier)
tableView.register(ScenicQueueSkippedCell.self, forCellReuseIdentifier: ScenicQueueSkippedCell.reuseIdentifier)
tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
+ configureDataSource()
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.font = ScenicQueueTokens.emptyStateFont
@@ -114,7 +107,7 @@ final class ScenicQueueViewController: BaseViewController {
view.addSubview(punchSpotRow)
view.addSubview(statsBanner)
view.addSubview(contentContainer)
- contentContainer.addSubview(segmentedControl)
+ contentContainer.addSubview(tabStrip)
contentContainer.addSubview(tableView)
contentContainer.addSubview(emptyLabel)
view.addSubview(bottomBar)
@@ -149,12 +142,13 @@ final class ScenicQueueViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top).offset(-8)
}
- segmentedControl.snp.makeConstraints { make in
- make.top.leading.trailing.equalToSuperview().inset(12)
- make.height.equalTo(40)
+ tabStrip.snp.makeConstraints { make in
+ make.top.equalToSuperview()
+ make.leading.trailing.equalToSuperview().inset(12)
+ make.height.equalTo(48)
}
tableView.snp.makeConstraints { make in
- make.top.equalTo(segmentedControl.snp.bottom).offset(8)
+ make.top.equalTo(tabStrip.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
emptyLabel.snp.makeConstraints { make in
@@ -177,7 +171,7 @@ final class ScenicQueueViewController: BaseViewController {
}
override func bindActions() {
- segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged)
+ tabStrip.onSelect = { [weak self] index in self?.selectTab(index) }
prepareCallButton.addTarget(self, action: #selector(prepareCallTapped), for: .touchUpInside)
quickCallButton.addTarget(self, action: #selector(quickCallTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
@@ -202,7 +196,7 @@ final class ScenicQueueViewController: BaseViewController {
punchSpotLabel.text = viewModel.selectedPunchSpotLine
statsBanner.configure(summary: viewModel.summary)
tableView.refreshControl?.endRefreshing()
- tableView.reloadData()
+ applySnapshot(animated: true)
let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty
emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录"
emptyLabel.isHidden = !isEmpty
@@ -212,6 +206,41 @@ final class ScenicQueueViewController: BaseViewController {
bottomBar.isHidden = !viewModel.queueGatePassed || (!viewModel.prepareCallButtonEnabled && !viewModel.quickCallButtonEnabled)
}
+ private func configureDataSource() {
+ dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
+ guard let self else { return UITableViewCell() }
+ switch item {
+ case .current(let ticket):
+ let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell
+ cell.delegate = self
+ cell.configure(
+ ticket: ticket,
+ shootingQueueNo: self.viewModel.shootingTimedQueueNo,
+ remainSeconds: self.viewModel.shootingRemainSeconds,
+ showStartShootingButton: self.viewModel.showStartShootingButton
+ )
+ return cell
+ case .skipped(let ticket):
+ let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell
+ cell.delegate = self
+ cell.configure(ticket: ticket)
+ return cell
+ }
+ }
+ }
+
+ private func applySnapshot(animated: Bool) {
+ let previousItems = Set(dataSource.snapshot().itemIdentifiers)
+ var snapshot = NSDiffableDataSourceSnapshot()
+ snapshot.appendSections([0])
+ let items: [ScenicQueueListItem] = selectedTab == 0
+ ? viewModel.currentQueue.map(ScenicQueueListItem.current)
+ : viewModel.skippedQueue.map(ScenicQueueListItem.skipped)
+ snapshot.appendItems(items)
+ snapshot.reconfigureItems(items.filter(previousItems.contains))
+ dataSource.apply(snapshot, animatingDifferences: animated)
+ }
+
private func configureBottomButton(_ button: UIButton, title: String, subtitle: String, image: UIImage?, color: UIColor) {
var config = UIButton.Configuration.filled()
config.baseBackgroundColor = color
@@ -232,42 +261,63 @@ final class ScenicQueueViewController: BaseViewController {
private func presentParametersRequiredDialog() {
guard presentedViewController == nil else { return }
- let alert = UIAlertController(title: "排队参数未配置", message: "请先选择打卡点并保存排队设置", preferredStyle: .alert)
- alert.addAction(UIAlertAction(title: "暂不进入", style: .cancel) { [weak self] _ in
- self?.viewModel.exitQueueModule()
- })
- alert.addAction(UIAlertAction(title: "去设置", style: .default) { [weak self] _ in
- self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true)
- })
- present(alert, animated: true)
+ let dialog = ScenicQueueConfirmationDialogViewController(
+ configuration: ScenicQueueDialogConfiguration(
+ title: "请先设置排队参数",
+ message: "请在排队设置中选择并保存打卡点后,再使用排队管理。",
+ emphasizedText: nil,
+ dangerText: nil,
+ cancelTitle: "暂不使用",
+ confirmTitle: "去设置",
+ buttonRadius: 22,
+ dismissOnBackdrop: false
+ ),
+ onCancel: { [weak self] in self?.viewModel.exitQueueModule() },
+ onConfirm: { [weak self] in self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) }
+ )
+ present(dialog, animated: true)
}
- private func presentConfirm(title: String, message: String?, confirm: @escaping () -> Void) {
- let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
- alert.addAction(UIAlertAction(title: "取消", style: .cancel))
- alert.addAction(UIAlertAction(title: "确认", style: .default) { _ in confirm() })
- present(alert, animated: true)
+ private func presentConfirm(
+ title: String,
+ emphasizedText: String,
+ dangerText: String? = nil,
+ confirmTitle: String = "确认",
+ cancel: @escaping () -> Void,
+ confirm: @escaping () -> Void
+ ) {
+ let dialog = ScenicQueueConfirmationDialogViewController(
+ configuration: ScenicQueueDialogConfiguration(
+ title: title,
+ message: nil,
+ emphasizedText: emphasizedText,
+ dangerText: dangerText,
+ cancelTitle: "取消",
+ confirmTitle: confirmTitle,
+ buttonRadius: title == "确认完成拍摄" ? 22 : 8,
+ dismissOnBackdrop: true
+ ),
+ onCancel: cancel,
+ onConfirm: confirm
+ )
+ present(dialog, animated: true)
}
private func openDial(_ digits: String) {
guard !digits.isEmpty else { return }
- let alert = UIAlertController(title: "拨打电话", message: digits, preferredStyle: .actionSheet)
- alert.addAction(UIAlertAction(title: "取消", style: .cancel))
- alert.addAction(UIAlertAction(title: "拨打", style: .default) { _ in
- if let url = URL(string: "tel:\(digits)") {
- UIApplication.shared.open(url)
- }
- })
- present(alert, animated: true)
+ present(ScenicQueueDialSheetViewController(digits: digits), animated: true)
}
@objc private func settingsTapped() {
viewModel.openSettings()
}
- @objc private func tabChanged() {
- selectedTab = segmentedControl.selectedSegmentIndex
- tableView.reloadData()
+ private func selectTab(_ index: Int) {
+ guard index != selectedTab else { return }
+ selectedTab = index
+ UIView.transition(with: tableView, duration: 0.22, options: .transitionCrossDissolve) {
+ self.applySnapshot(animated: false)
+ }
applyState()
}
@@ -298,30 +348,7 @@ final class ScenicQueueViewController: BaseViewController {
}
}
-extension ScenicQueueViewController: UITableViewDataSource, UITableViewDelegate {
- func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
- selectedTab == 0 ? viewModel.currentQueue.count : viewModel.skippedQueue.count
- }
-
- func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
- if selectedTab == 0 {
- let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueTicketCell.reuseIdentifier, for: indexPath) as! ScenicQueueTicketCell
- let ticket = viewModel.currentQueue[indexPath.row]
- cell.delegate = self
- cell.configure(
- ticket: ticket,
- shootingQueueNo: viewModel.shootingTimedQueueNo,
- remainSeconds: viewModel.shootingRemainSeconds,
- showStartShootingButton: viewModel.showStartShootingButton
- )
- return cell
- }
- let cell = tableView.dequeueReusableCell(withIdentifier: ScenicQueueSkippedCell.reuseIdentifier, for: indexPath) as! ScenicQueueSkippedCell
- cell.delegate = self
- cell.configure(ticket: viewModel.skippedQueue[indexPath.row])
- return cell
- }
-
+extension ScenicQueueViewController: UITableViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return }
Task {
@@ -341,7 +368,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
viewModel.requestSkipTicket(recordId: ticket.recordId, queueNo: ticket.queueNo)
- presentConfirm(title: "确认过号", message: ticket.queueNo) { [weak self] in
+ presentConfirm(title: "确认过号", emphasizedText: ticket.queueNo, dangerText: "确认该用户未到场并过号", cancel: { [weak self] in
+ self?.viewModel.dismissSkipDialog()
+ }) { [weak self] in
guard let self else { return }
Task { await self.viewModel.confirmSkipTicket(api: self.api) }
}
@@ -350,7 +379,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
if ticket.status == .waiting {
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
- presentConfirm(title: "确认叫号", message: ticket.queueNo) { [weak self] in
+ presentConfirm(title: "确认叫号", emphasizedText: "确认叫号【\(ticket.queueNo)】", cancel: { [weak self] in
+ self?.viewModel.dismissCallConfirmDialog()
+ }) { [weak self] in
guard let self else { return }
Task { await self.viewModel.confirmCallTicket(api: self.api) }
}
@@ -361,7 +392,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo, isRecall: true)
- presentConfirm(title: "重新叫号", message: ticket.queueNo) { [weak self] in
+ presentConfirm(title: "重新叫号", emphasizedText: "确认叫号【\(ticket.queueNo)】", cancel: { [weak self] in
+ self?.viewModel.dismissCallConfirmDialog()
+ }) { [weak self] in
guard let self else { return }
Task { await self.viewModel.confirmCallTicket(api: self.api) }
}
@@ -369,7 +402,9 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
viewModel.requestFinishConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
- presentConfirm(title: "确认完成拍摄", message: ticket.queueNo) { [weak self] in
+ presentConfirm(title: "确认完成拍摄", emphasizedText: ticket.queueNo, cancel: { [weak self] in
+ self?.viewModel.dismissFinishDialog()
+ }) { [weak self] in
guard let self else { return }
Task { await self.viewModel.confirmFinishTicket(api: self.api) }
}
@@ -377,25 +412,16 @@ extension ScenicQueueViewController: ScenicQueueTicketCellDelegate {
func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) {
viewModel.onTicketMarkClick(recordId: ticket.recordId, queueNo: ticket.queueNo, uid: ticket.uid)
- let alert = UIAlertController(title: "标记用户", message: "\(ticket.queueNo)\n\(ticket.phoneMasked)", preferredStyle: .alert)
- alert.addTextField { field in
- field.placeholder = "限制排队天数"
- field.keyboardType = .numberPad
- field.text = "0"
- }
- alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
- self?.viewModel.dismissMarkDialog()
- })
- alert.addAction(UIAlertAction(title: "标记打野", style: .default) { [weak self, weak alert] _ in
- guard let self else { return }
- let days = Int(alert?.textFields?.first?.text ?? "") ?? 0
- Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: true, queueBanDays: days) }
- })
- alert.addAction(UIAlertAction(title: "取消标记", style: .destructive) { [weak self] _ in
- guard let self else { return }
- Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: false, queueBanDays: 0) }
- })
- present(alert, animated: true)
+ guard let pending = viewModel.pendingMark else { return }
+ let dialog = ScenicQueueMarkDialogViewController(
+ pending: pending,
+ onCancel: { [weak self] in self?.viewModel.dismissMarkDialog() },
+ onConfirm: { [weak self] marked, days in
+ guard let self else { return }
+ Task { await self.viewModel.confirmUserMark(api: self.api, markAsFreelancePhotog: marked, queueBanDays: days) }
+ }
+ )
+ present(dialog, animated: true)
}
}
@@ -406,7 +432,9 @@ extension ScenicQueueViewController: ScenicQueueSkippedCellDelegate {
func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) {
viewModel.requestRequeueConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo)
- presentConfirm(title: "确认重新排队", message: ticket.queueNo) { [weak self] in
+ presentConfirm(title: "确认重新排队", emphasizedText: ticket.queueNo, dangerText: "确认后将插入当前排队队列", confirmTitle: "确定", cancel: { [weak self] in
+ self?.viewModel.dismissRequeueDialog()
+ }) { [weak self] in
guard let self else { return }
Task { await self.viewModel.confirmRequeueTicket(api: self.api) }
}
diff --git a/suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift b/suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift
new file mode 100644
index 0000000..ec337da
--- /dev/null
+++ b/suixinkan/UI/ScenicQueue/Views/ScenicQueueOverlayViews.swift
@@ -0,0 +1,396 @@
+//
+// ScenicQueueOverlayViews.swift
+// suixinkan
+//
+
+import SnapKit
+import UIKit
+
+/// Android 排队模块样式的等宽标签栏。
+final class ScenicQueueTabStripView: UIView {
+ private let stackView = UIStackView()
+ private let indicator = UIView()
+ private let divider = UIView()
+ private var buttons: [UIButton] = []
+
+ /// 用户选择标签时回传索引。
+ var onSelect: ((Int) -> Void)?
+
+ /// 当前选中索引。
+ private(set) var selectedIndex = 0
+
+ init(titles: [String]) {
+ super.init(frame: .zero)
+ backgroundColor = .white
+ stackView.axis = .horizontal
+ stackView.distribution = .fillEqually
+ titles.enumerated().forEach { index, title in
+ let button = UIButton(type: .system)
+ button.tag = index
+ button.setTitle(title, for: .normal)
+ button.titleLabel?.font = ScenicQueueTokens.tabFont
+ button.addTarget(self, action: #selector(buttonTapped(_:)), for: .touchUpInside)
+ buttons.append(button)
+ stackView.addArrangedSubview(button)
+ }
+ indicator.backgroundColor = ScenicQueueTokens.bannerBlue
+ divider.backgroundColor = ScenicQueueTokens.cardOutline
+ addSubview(stackView)
+ addSubview(divider)
+ addSubview(indicator)
+ stackView.snp.makeConstraints { $0.edges.equalToSuperview() }
+ divider.snp.makeConstraints { make in
+ make.leading.trailing.bottom.equalToSuperview()
+ make.height.equalTo(1 / UIScreen.main.scale)
+ }
+ setSelectedIndex(0, animated: false)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func layoutSubviews() {
+ super.layoutSubviews()
+ updateIndicatorFrame()
+ }
+
+ /// 更新选中标签并移动 3pt 蓝色指示条。
+ func setSelectedIndex(_ index: Int, animated: Bool) {
+ guard buttons.indices.contains(index) else { return }
+ selectedIndex = index
+ buttons.enumerated().forEach { item in
+ item.element.setTitleColor(item.offset == index ? ScenicQueueTokens.bannerBlue : AppColor.textSecondary, for: .normal)
+ item.element.titleLabel?.font = item.offset == index ? ScenicQueueTokens.tabSelectedFont : ScenicQueueTokens.tabFont
+ }
+ let changes = { self.updateIndicatorFrame() }
+ animated ? UIView.animate(withDuration: 0.22, animations: changes) : changes()
+ }
+
+ private func updateIndicatorFrame() {
+ guard !buttons.isEmpty, bounds.width > 0 else { return }
+ let width = bounds.width / CGFloat(buttons.count)
+ indicator.frame = CGRect(x: width * CGFloat(selectedIndex), y: bounds.height - 3, width: width, height: 3)
+ }
+
+ @objc private func buttonTapped(_ sender: UIButton) {
+ setSelectedIndex(sender.tag, animated: true)
+ onSelect?(sender.tag)
+ }
+}
+
+/// 排队模块确认弹层的固定展示配置。
+struct ScenicQueueDialogConfiguration {
+ let title: String
+ let message: String?
+ let emphasizedText: String?
+ let dangerText: String?
+ let cancelTitle: String
+ let confirmTitle: String
+ let buttonRadius: CGFloat
+ let dismissOnBackdrop: Bool
+}
+
+/// Android 排队模块样式的居中确认弹层。
+final class ScenicQueueConfirmationDialogViewController: UIViewController, UIGestureRecognizerDelegate {
+ private let configuration: ScenicQueueDialogConfiguration
+ private let onCancel: () -> Void
+ private let onConfirm: () -> Void
+
+ init(configuration: ScenicQueueDialogConfiguration, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
+ self.configuration = configuration
+ self.onCancel = onCancel
+ self.onConfirm = onConfirm
+ super.init(nibName: nil, bundle: nil)
+ modalPresentationStyle = .overFullScreen
+ modalTransitionStyle = .crossDissolve
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.42)
+ if configuration.dismissOnBackdrop {
+ let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
+ backdropTap.delegate = self
+ view.addGestureRecognizer(backdropTap)
+ }
+
+ let card = UIView()
+ card.backgroundColor = .white
+ card.layer.cornerRadius = ScenicQueueTokens.radiusDialog
+ card.layer.shadowColor = UIColor.black.cgColor
+ card.layer.shadowOpacity = 0.18
+ card.layer.shadowRadius = 8
+
+ let titleLabel = UILabel()
+ titleLabel.text = configuration.title
+ titleLabel.font = ScenicQueueTokens.dialogTitleFont
+ titleLabel.textColor = AppColor.textPrimary
+ titleLabel.textAlignment = .center
+
+ let contentStack = UIStackView(arrangedSubviews: [titleLabel])
+ contentStack.axis = .vertical
+ contentStack.spacing = 10
+ if let message = configuration.message {
+ let label = makeLabel(message, font: .systemFont(ofSize: 15), color: AppColor.textSecondary)
+ contentStack.addArrangedSubview(label)
+ }
+ if let danger = configuration.dangerText {
+ let label = makeLabel(danger, font: .systemFont(ofSize: 13), color: ScenicQueueTokens.dialogDangerLine)
+ contentStack.addArrangedSubview(label)
+ }
+ if let emphasized = configuration.emphasizedText {
+ let label = makeLabel(emphasized, font: ScenicQueueTokens.dialogTicketFont, color: AppColor.textPrimary)
+ contentStack.addArrangedSubview(label)
+ contentStack.setCustomSpacing(20, after: label)
+ }
+
+ let cancel = makeButton(configuration.cancelTitle, background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
+ cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
+ let confirm = makeButton(configuration.confirmTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
+ confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
+ cancel.layer.cornerRadius = configuration.buttonRadius
+ confirm.layer.cornerRadius = configuration.buttonRadius
+ let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
+ buttons.axis = .horizontal
+ buttons.distribution = .fillEqually
+ buttons.spacing = 12
+ contentStack.addArrangedSubview(buttons)
+
+ view.addSubview(card)
+ card.addSubview(contentStack)
+ card.snp.makeConstraints { make in
+ make.center.equalToSuperview()
+ make.leading.trailing.equalToSuperview().inset(32)
+ }
+ contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
+ buttons.snp.makeConstraints { $0.height.equalTo(44) }
+ }
+
+ private func makeLabel(_ text: String, font: UIFont, color: UIColor) -> UILabel {
+ let label = UILabel()
+ label.text = text
+ label.font = font
+ label.textColor = color
+ label.textAlignment = .center
+ label.numberOfLines = 0
+ return label
+ }
+
+ private func makeButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
+ let button = UIButton(type: .system)
+ button.setTitle(title, for: .normal)
+ button.setTitleColor(foreground, for: .normal)
+ button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
+ button.backgroundColor = background
+ button.clipsToBounds = true
+ return button
+ }
+
+ @objc private func cancelTapped() {
+ dismiss(animated: true, completion: onCancel)
+ }
+
+ @objc private func confirmTapped() {
+ dismiss(animated: true, completion: onConfirm)
+ }
+
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
+ touch.view === view
+ }
+}
+
+/// Android 排队模块样式的拨号底部弹层。
+final class ScenicQueueDialSheetViewController: UIViewController, UIGestureRecognizerDelegate {
+ private let digits: String
+
+ init(digits: String) {
+ self.digits = digits
+ super.init(nibName: nil, bundle: nil)
+ modalPresentationStyle = .overFullScreen
+ modalTransitionStyle = .crossDissolve
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.36)
+ let backdropTap = UITapGestureRecognizer(target: self, action: #selector(cancelTapped))
+ backdropTap.delegate = self
+ view.addGestureRecognizer(backdropTap)
+ let panel = UIView()
+ panel.backgroundColor = .white
+ panel.layer.cornerRadius = 18
+ panel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
+ let title = UILabel()
+ title.text = "拨打电话"
+ title.font = ScenicQueueTokens.dialogTitleFont
+ title.textAlignment = .center
+ let number = UILabel()
+ number.text = digits
+ number.font = .systemFont(ofSize: 18, weight: .medium)
+ number.textColor = AppColor.textPrimary
+ number.textAlignment = .center
+ let cancel = pillButton("取消", background: ScenicQueueTokens.dialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
+ cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
+ let dial = pillButton("拨打", background: ScenicQueueTokens.bannerBlue, foreground: .white)
+ dial.addTarget(self, action: #selector(dialTapped), for: .touchUpInside)
+ let buttons = UIStackView(arrangedSubviews: [cancel, dial])
+ buttons.axis = .horizontal
+ buttons.distribution = .fillEqually
+ buttons.spacing = 12
+ view.addSubview(panel)
+ [title, number, buttons].forEach(panel.addSubview)
+ panel.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() }
+ title.snp.makeConstraints { make in make.top.equalToSuperview().offset(18); make.centerX.equalToSuperview() }
+ number.snp.makeConstraints { make in make.top.equalTo(title.snp.bottom).offset(18); make.leading.trailing.equalToSuperview().inset(16) }
+ buttons.snp.makeConstraints { make in
+ make.top.equalTo(number.snp.bottom).offset(18)
+ make.leading.trailing.equalToSuperview().inset(16)
+ make.height.equalTo(44)
+ make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
+ }
+ }
+
+ private func pillButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
+ let button = UIButton(type: .system)
+ button.setTitle(title, for: .normal)
+ button.setTitleColor(foreground, for: .normal)
+ button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
+ button.backgroundColor = background
+ button.layer.cornerRadius = 22
+ return button
+ }
+
+ @objc private func cancelTapped() { dismiss(animated: true) }
+
+ @objc private func dialTapped() {
+ dismiss(animated: true) {
+ guard let url = URL(string: "tel:\(self.digits)") else { return }
+ UIApplication.shared.open(url)
+ }
+ }
+
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
+ touch.view === view
+ }
+}
+
+/// Android 用户标记弹层,支持打野标记和限制排队天数步进。
+final class ScenicQueueMarkDialogViewController: UIViewController {
+ private let pending: ScenicQueuePendingMark
+ private let onCancel: () -> Void
+ private let onConfirm: (Bool, Int) -> Void
+ private let markSwitch = UISwitch()
+ private let daysLabel = UILabel()
+ private var days = 0 { didSet { daysLabel.text = "\(days)" } }
+
+ init(pending: ScenicQueuePendingMark, onCancel: @escaping () -> Void, onConfirm: @escaping (Bool, Int) -> Void) {
+ self.pending = pending
+ self.onCancel = onCancel
+ self.onConfirm = onConfirm
+ super.init(nibName: nil, bundle: nil)
+ modalPresentationStyle = .overFullScreen
+ modalTransitionStyle = .crossDissolve
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.42)
+ let card = UIView()
+ card.backgroundColor = .white
+ card.layer.cornerRadius = ScenicQueueTokens.markDialogRadius
+ let title = UILabel()
+ title.text = "标记用户"
+ title.font = ScenicQueueTokens.dialogTitleFont
+ title.textAlignment = .center
+ let queue = UILabel()
+ queue.text = pending.queueNo
+ queue.font = .systemFont(ofSize: 24, weight: .bold)
+ queue.textColor = ScenicQueueTokens.bannerBlue
+ let phone = UILabel()
+ phone.text = pending.phoneMasked
+ phone.font = ScenicQueueTokens.phoneMaskedFont
+ phone.textColor = AppColor.textSecondary
+ let identityTitle = UILabel()
+ identityTitle.text = "标记为打野摄影师"
+ identityTitle.font = ScenicQueueTokens.settingsRowTitleFont
+ markSwitch.isOn = pending.markAsFreelancePhotog == 1
+ markSwitch.onTintColor = ScenicQueueTokens.bannerBlue
+ let identityRow = UIStackView(arrangedSubviews: [identityTitle, UIView(), markSwitch])
+ identityRow.axis = .horizontal
+ identityRow.alignment = .center
+ let hint = UILabel()
+ hint.text = "标记后,该用户扫码时将受排队限制。"
+ hint.font = ScenicQueueTokens.settingsHintFont
+ hint.textColor = AppColor.textTertiary
+ hint.numberOfLines = 0
+ let daysTitle = UILabel()
+ daysTitle.text = "限制排队天数"
+ daysTitle.font = ScenicQueueTokens.settingsRowTitleFont
+ let minus = stepButton("−", action: #selector(minusTapped))
+ let plus = stepButton("+", action: #selector(plusTapped))
+ daysLabel.text = "0"
+ daysLabel.font = .systemFont(ofSize: 16, weight: .medium)
+ daysLabel.textAlignment = .center
+ let stepper = UIStackView(arrangedSubviews: [minus, daysLabel, plus])
+ stepper.axis = .horizontal
+ stepper.alignment = .center
+ stepper.distribution = .fillEqually
+ stepper.backgroundColor = ScenicQueueTokens.markDialogStepperBackground
+ stepper.layer.cornerRadius = 10
+ let cancel = actionButton("取消", background: ScenicQueueTokens.markDialogCancelBackground, foreground: ScenicQueueTokens.bannerBlue)
+ cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
+ let confirm = actionButton("完成", background: ScenicQueueTokens.bannerBlue, foreground: .white)
+ confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
+ let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
+ buttons.axis = .horizontal
+ buttons.distribution = .fillEqually
+ buttons.spacing = 12
+ let stack = UIStackView(arrangedSubviews: [title, queue, phone, identityRow, hint, daysTitle, stepper, buttons])
+ stack.axis = .vertical
+ stack.spacing = 12
+ view.addSubview(card)
+ card.addSubview(stack)
+ card.snp.makeConstraints { make in make.center.equalToSuperview(); make.width.equalToSuperview().multipliedBy(0.88) }
+ stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(22) }
+ stepper.snp.makeConstraints { $0.height.equalTo(44) }
+ buttons.snp.makeConstraints { $0.height.equalTo(44) }
+ }
+
+ private func stepButton(_ title: String, action: Selector) -> UIButton {
+ let button = UIButton(type: .system)
+ button.setTitle(title, for: .normal)
+ button.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal)
+ button.titleLabel?.font = .systemFont(ofSize: 22, weight: .medium)
+ button.addTarget(self, action: action, for: .touchUpInside)
+ return button
+ }
+
+ private func actionButton(_ title: String, background: UIColor, foreground: UIColor) -> UIButton {
+ let button = UIButton(type: .system)
+ button.setTitle(title, for: .normal)
+ button.setTitleColor(foreground, for: .normal)
+ button.titleLabel?.font = ScenicQueueTokens.dialogButtonFont
+ button.backgroundColor = background
+ button.layer.cornerRadius = ScenicQueueTokens.markDialogButtonRadius
+ return button
+ }
+
+ @objc private func minusTapped() { days = max(0, days - 1) }
+ @objc private func plusTapped() { days = min(999, days + 1) }
+ @objc private func cancelTapped() { dismiss(animated: true, completion: onCancel) }
+ @objc private func confirmTapped() {
+ let marked = markSwitch.isOn
+ dismiss(animated: true) { self.onConfirm(marked, marked ? self.days : 0) }
+ }
+}
diff --git a/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift b/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift
index 32049aa..5c13bb6 100644
--- a/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift
+++ b/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift
@@ -261,33 +261,38 @@ final class ScenicQueueTicketCell: UITableViewCell {
private func rebuildActions(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int) {
actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
+ let state = ScenicQueueTicketActionState.make(
+ ticket: ticket,
+ shootingQueueNo: shootingQueueNo,
+ remainSeconds: remainSeconds,
+ showStartShootingButton: showStartShootingButton
+ )
let skip = ScenicQueueActionButton(title: "过号", background: ScenicQueueTokens.buttonSkipBackground, foreground: ScenicQueueTokens.buttonSkipForeground)
skip.addTarget(self, action: #selector(skipTapped), for: .touchUpInside)
- let primaryTitle: String
- if shootingQueueNo == ticket.queueNo {
- primaryTitle = "剩余 \(max(0, remainSeconds)) 秒"
- } else {
- primaryTitle = ticket.status == .called ? "开始拍摄" : "叫号"
- }
- let primary = ScenicQueueActionButton(title: primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
- primary.isEnabled = shootingQueueNo != ticket.queueNo
- primary.alpha = primary.isEnabled ? 1 : 0.65
- primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
- let row = UIStackView(arrangedSubviews: [skip, primary])
+ skip.isEnabled = state.skipEnabled
+ skip.alpha = state.skipEnabled ? 1 : 0.45
+ let row = UIStackView(arrangedSubviews: [skip])
row.axis = .horizontal
row.spacing = 10
row.distribution = .fillProportionally
- skip.snp.makeConstraints { make in
- make.height.equalTo(46)
- make.width.equalTo(primary).multipliedBy(0.56)
+ if state.primaryVisible {
+ let primary = ScenicQueueActionButton(title: state.primaryTitle, background: ScenicQueueTokens.bannerBlue, foreground: .white)
+ primary.isEnabled = state.primaryEnabled
+ primary.alpha = state.primaryEnabled ? 1 : 0.65
+ primary.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
+ row.addArrangedSubview(primary)
+ skip.snp.makeConstraints { $0.width.equalTo(primary).multipliedBy(0.56) }
}
+ row.snp.makeConstraints { $0.height.equalTo(46) }
actionsStack.addArrangedSubview(row)
- if ticket.status == .called, shootingQueueNo != ticket.queueNo {
+ recallButton.removeTarget(nil, action: nil, for: .allEvents)
+ completeButton.removeTarget(nil, action: nil, for: .allEvents)
+ if state.recallVisible {
recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside)
recallButton.snp.makeConstraints { make in make.height.equalTo(40) }
actionsStack.addArrangedSubview(recallButton)
}
- if ticket.showCompleteAction {
+ if state.completeVisible {
completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside)
completeButton.snp.makeConstraints { make in make.height.equalTo(40) }
actionsStack.addArrangedSubview(completeButton)
@@ -329,17 +334,27 @@ final class ScenicQueueTicketCell: UITableViewCell {
right.backgroundColor = accent ? AppColor.primaryLight : .clear
right.layer.cornerRadius = 2
right.clipsToBounds = true
- if let tap {
- right.isUserInteractionEnabled = true
- right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
- }
row.addSubview(left)
- row.addSubview(right)
+ let valueContainer = UIStackView(arrangedSubviews: [right])
+ valueContainer.axis = .horizontal
+ valueContainer.alignment = .center
+ valueContainer.spacing = 8
+ if let tap {
+ let dial = UIButton(type: .system)
+ dial.backgroundColor = ScenicQueueTokens.bannerBlue
+ dial.tintColor = .white
+ dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal)
+ dial.layer.cornerRadius = 16
+ dial.addTarget(self, action: tap, for: .touchUpInside)
+ valueContainer.addArrangedSubview(dial)
+ dial.snp.makeConstraints { $0.size.equalTo(32) }
+ }
+ row.addSubview(valueContainer)
left.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.top.bottom.equalToSuperview()
}
- right.snp.makeConstraints { make in
+ valueContainer.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
}
@@ -490,17 +505,27 @@ final class ScenicQueueSkippedCell: UITableViewCell {
right.text = value
right.font = ScenicQueueTokens.phoneMaskedFont
right.textColor = AppColor.textPrimary
- if let tap {
- right.isUserInteractionEnabled = true
- right.addGestureRecognizer(UITapGestureRecognizer(target: self, action: tap))
- }
row.addSubview(left)
- row.addSubview(right)
+ let valueContainer = UIStackView(arrangedSubviews: [right])
+ valueContainer.axis = .horizontal
+ valueContainer.alignment = .center
+ valueContainer.spacing = 8
+ if let tap {
+ let dial = UIButton(type: .system)
+ dial.backgroundColor = ScenicQueueTokens.bannerBlue
+ dial.tintColor = .white
+ dial.setImage(UIImage(systemName: "phone.fill")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal)
+ dial.layer.cornerRadius = 16
+ dial.addTarget(self, action: tap, for: .touchUpInside)
+ valueContainer.addArrangedSubview(dial)
+ dial.snp.makeConstraints { $0.size.equalTo(32) }
+ }
+ row.addSubview(valueContainer)
left.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.top.bottom.equalToSuperview()
}
- right.snp.makeConstraints { make in
+ valueContainer.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12)
}
diff --git a/suixinkan/UI/Setting/SettingViewController.swift b/suixinkan/UI/Setting/SettingViewController.swift
index 58c9290..2f52403 100644
--- a/suixinkan/UI/Setting/SettingViewController.swift
+++ b/suixinkan/UI/Setting/SettingViewController.swift
@@ -19,10 +19,10 @@ final class SettingViewController: BaseViewController {
init(
viewModel: SettingViewModel = SettingViewModel(),
- settingAPI: SettingAPI = NetworkServices.shared.settingAPI
+ settingAPI: SettingAPI? = nil
) {
self.viewModel = viewModel
- self.settingAPI = settingAPI
+ self.settingAPI = settingAPI ?? NetworkServices.shared.settingAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
index a59a1ed..5632320 100644
--- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
+++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift
@@ -28,9 +28,9 @@ final class TravelAlbumDetailViewController: BaseViewController {
private let uploadButton = UIButton(type: .system)
private var isDeleteSelectedButtonVisible = false
- init(albumId: Int, api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
+ init(albumId: Int, api: (any TravelAlbumServing)? = nil) {
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
- self.api = api
+ self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift
index 24f43ba..75f4375 100644
--- a/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift
+++ b/suixinkan/UI/TravelAlbum/TravelAlbumEntryViewController.swift
@@ -18,8 +18,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
private let emptyView = TravelAlbumEmptyView()
private var dataSource: UITableViewDiffableDataSource!
- init(api: any TravelAlbumServing = NetworkServices.shared.travelAlbumAPI) {
- self.api = api
+ init(api: (any TravelAlbumServing)? = nil) {
+ self.api = api ?? NetworkServices.shared.travelAlbumAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/Wallet/PointsRedemptionViewController.swift b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift
index b1b6ebb..cdde48b 100644
--- a/suixinkan/UI/Wallet/PointsRedemptionViewController.swift
+++ b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift
@@ -36,10 +36,10 @@ final class PointsRedemptionViewController: BaseViewController, UITableViewDeleg
/// 初始化积分兑现页面。
init(
viewModel: PointsRedemptionViewModel = PointsRedemptionViewModel(),
- walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
+ walletAPI: (any WalletPageServing)? = nil
) {
self.viewModel = viewModel
- self.walletAPI = walletAPI
+ self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/Wallet/WalletViewController.swift b/suixinkan/UI/Wallet/WalletViewController.swift
index cb53dc7..865b7a4 100644
--- a/suixinkan/UI/Wallet/WalletViewController.swift
+++ b/suixinkan/UI/Wallet/WalletViewController.swift
@@ -41,12 +41,12 @@ final class WalletViewController: BaseViewController, UITableViewDelegate {
/// 初始化钱包页面。
init(
viewModel: WalletViewModel = WalletViewModel(),
- walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI,
- profileAPI: any WalletProfileServing = NetworkServices.shared.profileAPI
+ walletAPI: (any WalletPageServing)? = nil,
+ profileAPI: (any WalletProfileServing)? = nil
) {
self.viewModel = viewModel
- self.walletAPI = walletAPI
- self.profileAPI = profileAPI
+ self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
+ self.profileAPI = profileAPI ?? NetworkServices.shared.profileAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/Wallet/WithdrawViewController.swift b/suixinkan/UI/Wallet/WithdrawViewController.swift
index e8491b1..3162f81 100644
--- a/suixinkan/UI/Wallet/WithdrawViewController.swift
+++ b/suixinkan/UI/Wallet/WithdrawViewController.swift
@@ -34,10 +34,10 @@ final class WithdrawViewController: BaseViewController {
/// 初始化提现申请页面。
init(
viewModel: WithdrawViewModel = WithdrawViewModel(),
- walletAPI: any WalletPageServing = NetworkServices.shared.walletAPI
+ walletAPI: (any WalletPageServing)? = nil
) {
self.viewModel = viewModel
- self.walletAPI = walletAPI
+ self.walletAPI = walletAPI ?? NetworkServices.shared.walletAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift
index 93cdbad..16e7806 100644
--- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift
+++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift
@@ -15,10 +15,10 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
init(
viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(),
- api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
+ api: (any WildPhotographerReportServing)? = nil
) {
self.viewModel = viewModel
- self.api = api
+ self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift
index efed8c9..169b5bb 100644
--- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift
+++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift
@@ -18,10 +18,10 @@ final class WildPhotographerReportListViewController: BaseViewController {
init(
homeViewModel: WildPhotographerReportHomeViewModel,
- api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
+ api: (any WildPhotographerReportServing)? = nil
) {
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
- self.api = api
+ self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
super.init(nibName: nil, bundle: nil)
}
@@ -560,11 +560,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
init(
record: WildReportRecord,
homeViewModel: WildPhotographerReportHomeViewModel,
- api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
+ api: (any WildPhotographerReportServing)? = nil
) {
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
self.homeViewModel = homeViewModel
- self.api = api
+ self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift
index 7a0aa34..e9633dc 100644
--- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift
+++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift
@@ -16,6 +16,8 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let descriptionView = UITextView()
+ private let descriptionPlaceholderLabel = UILabel()
+ private let descriptionCountLabel = UILabel()
private let contactField = UITextField()
private let submitButton = UIButton(type: .system)
private var pickerTarget: WildReportPickerTarget = .image
@@ -25,12 +27,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
init(
homeViewModel: WildPhotographerReportHomeViewModel,
locationProvider: any LocationProviding = LocationProvider.shared,
- api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
- uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService
+ api: (any WildPhotographerReportServing)? = nil,
+ uploader: (any WildReportAttachmentUploading)? = nil
) {
self.homeViewModel = homeViewModel
- self.api = api
- self.uploader = uploader
+ self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
+ self.uploader = uploader ?? NetworkServices.shared.ossUploadService
self.viewModel = WildPhotographerReportSubmitViewModel(
homeViewModel: homeViewModel,
locationProvider: locationProvider
@@ -64,6 +66,14 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
descriptionView.delegate = self
+ descriptionPlaceholderLabel.text = "请描述摄影师的位置、特征、行为..."
+ descriptionPlaceholderLabel.font = .systemFont(ofSize: 15)
+ descriptionPlaceholderLabel.textColor = UIColor(hex: 0x9CA3AF)
+
+ descriptionCountLabel.font = .systemFont(ofSize: 14)
+ descriptionCountLabel.textColor = UIColor(hex: 0x6B7280)
+ descriptionCountLabel.textAlignment = .right
+
contactField.placeholder = "请输入摄影师微信号或手机号(选填)"
contactField.font = .app(.body)
contactField.textColor = AppColor.textPrimary
@@ -271,34 +281,28 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
private func makeDescriptionCard() -> UIView {
let card = sectionCard(index: 3, title: "举报说明")
descriptionView.text = viewModel.description
+ updateDescriptionInputState()
let inputContainer = UIView()
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
inputContainer.layer.cornerRadius = 12
inputContainer.layer.borderWidth = 1
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
- let placeholder = UILabel()
- placeholder.text = "请描述摄影师的位置、特征、行为..."
- placeholder.font = .systemFont(ofSize: 15)
- placeholder.textColor = UIColor(hex: 0x9CA3AF)
- placeholder.isHidden = !viewModel.description.isEmpty
-
- let count = WildReportUI.label("\(viewModel.description.count)/500", font: .systemFont(ofSize: 14), color: UIColor(hex: 0x6B7280))
- count.textAlignment = .right
-
inputContainer.addSubview(descriptionView)
- inputContainer.addSubview(placeholder)
- inputContainer.addSubview(count)
+ inputContainer.addSubview(descriptionPlaceholderLabel)
+ inputContainer.addSubview(descriptionCountLabel)
descriptionView.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(132)
}
- placeholder.snp.makeConstraints { make in
- make.top.equalToSuperview().offset(18)
- make.leading.equalToSuperview().offset(14)
+ let textOriginX = descriptionView.textContainerInset.left
+ + descriptionView.textContainer.lineFragmentPadding
+ descriptionPlaceholderLabel.snp.makeConstraints { make in
+ make.top.equalToSuperview().offset(descriptionView.textContainerInset.top)
+ make.leading.equalToSuperview().offset(textOriginX)
make.trailing.lessThanOrEqualToSuperview().inset(14)
}
- count.snp.makeConstraints { make in
+ descriptionCountLabel.snp.makeConstraints { make in
make.trailing.bottom.equalToSuperview().inset(12)
}
card.stack.addArrangedSubview(inputContainer)
@@ -376,6 +380,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
return card
}
+ private func updateDescriptionInputState() {
+ descriptionPlaceholderLabel.isHidden = !viewModel.description.isEmpty
+ descriptionCountLabel.text = "\(viewModel.description.count)/500"
+ }
+
private func makePaymentCard() -> UIView {
let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true)
card.stack.addArrangedSubview(makeEvidenceUploadBlock(
@@ -561,7 +570,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
)
}
}
- @objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") }
+ @objc private func contactChanged() {
+ viewModel.updateContact(contactField.text ?? "")
+ guard contactField.markedTextRange == nil,
+ contactField.text != viewModel.contact else { return }
+ contactField.text = viewModel.contact
+ }
@objc private func dismissKeyboard() { view.endEditing(true) }
@MainActor
@@ -654,6 +668,10 @@ extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
viewModel.updateDescription(textView.text)
+ if textView.markedTextRange == nil, textView.text != viewModel.description {
+ textView.text = viewModel.description
+ }
+ updateDescriptionInputState()
}
func textViewDidEndEditing(_ textView: UITextView) {
diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift
index c0fa403..de7330a 100644
--- a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift
+++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift
@@ -32,13 +32,13 @@ final class WildReportRiskMapViewController: BaseViewController {
private var isShowingRiskMapLoading = false
init(
- api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
+ api: (any WildPhotographerReportServing)? = nil,
locationProvider: any LocationProviding = LocationProvider.shared,
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName }
) {
self.viewModel = WildReportRiskMapViewModel()
- self.api = api
+ self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
self.locationProvider = locationProvider
self.scenicIdProvider = scenicIdProvider
self.scenicNameProvider = scenicNameProvider
@@ -675,7 +675,7 @@ final class WildReportRiskMapViewController: BaseViewController {
button.tintColor = AppColor.primary
button.setTitleColor(AppColor.primary, for: .normal)
button.semanticContentAttribute = .forceRightToLeft
- button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4)
+ button.setConfigurationImagePlacement(.trailing, padding: 4)
if let phone {
button.addAction(UIAction { [weak self] _ in
self?.callPhone(phone)
diff --git a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift
index 584596a..f549144 100644
--- a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift
+++ b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift
@@ -28,13 +28,13 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
init(
record: WildReportRecord,
homeViewModel: WildPhotographerReportHomeViewModel,
- api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
- uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService,
+ api: (any WildPhotographerReportServing)? = nil,
+ uploader: (any WildReportAttachmentUploading)? = nil,
onSupplementSuccess: (() -> Void)? = nil
) {
self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
- self.api = api
- self.uploader = uploader
+ self.api = api ?? NetworkServices.shared.wildPhotographerReportAPI
+ self.uploader = uploader ?? NetworkServices.shared.ossUploadService
self.onSupplementSuccess = onSupplementSuccess
super.init(nibName: nil, bundle: nil)
}
diff --git a/suixinkanTests/ScenicQueueFeatureTests.swift b/suixinkanTests/ScenicQueueFeatureTests.swift
index 4444db3..7b58e38 100644
--- a/suixinkanTests/ScenicQueueFeatureTests.swift
+++ b/suixinkanTests/ScenicQueueFeatureTests.swift
@@ -4,6 +4,7 @@
//
import XCTest
+import UIKit
@testable import suixinkan
@MainActor
@@ -55,6 +56,24 @@ final class ScenicQueueFeatureTests: XCTestCase {
XCTAssertEqual(ticket.missRequeueText, "过号重排")
}
+ func testHomeDecodingAcceptsBackendStatsWithoutNestedType() throws {
+ let json = """
+ {
+ "stats":{"avg_wait_min":0,"queue_count":0},
+ "list":{"list":[],"page":1,"page_size":10,"total":0},
+ "type":1
+ }
+ """.data(using: .utf8)!
+
+ let home = try JSONDecoder().decode(ScenicQueueHomeData.self, from: json)
+
+ XCTAssertEqual(home.stats?.type, 0)
+ XCTAssertEqual(home.list?.list, [])
+ XCTAssertEqual(home.list?.page, 1)
+ XCTAssertEqual(home.list?.pageSize, 10)
+ XCTAssertEqual(home.list?.total, 0)
+ }
+
func testSettingDecodingClampsSnapshotAndSocketMessage() throws {
let settingJSON = """
{
@@ -399,6 +418,191 @@ final class ScenicQueueFeatureTests: XCTestCase {
XCTAssertFalse(store.scenicQueue.useOfflineTTS)
XCTAssertEqual(messages.last, "离线语音资源未安装")
}
+
+ func testQueueDistanceKilometerConversionAndInvalidFormats() {
+ XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 0), "0")
+ XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 500), "0.5")
+ XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 1_230), "1.23")
+ XCTAssertEqual(ScenicQueueSettingsViewModel.kilometersDisplay(fromMeters: 9_999_990), "9999.99")
+ XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "0.50"), 500)
+ XCTAssertEqual(ScenicQueueSettingsViewModel.meters(fromKilometersText: "9999.99"), 9_999_990)
+ XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "1.234"))
+ XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "10000"))
+ XCTAssertNil(ScenicQueueSettingsViewModel.meters(fromKilometersText: "公里"))
+ }
+
+ func testInvalidNumericPendingBlocksSaveAndSelectsMatchingTab() async {
+ let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.invalidNumeric")!
+ defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.invalidNumeric")
+ let store = AppStore(defaults: defaults)
+ store.session.userId = "u1"
+ store.session.accountType = .unknown("photog")
+ store.session.currentScenicId = 11
+ let api = MockScenicQueueAPI()
+ let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
+ await viewModel.setPunchSpot(ScenicQueuePunchSpotOption(id: "22", label: "东门打卡点"), api: api)
+
+ viewModel.reportOutOfRangeNumericPending(fieldKey: ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue, pending: true)
+ await viewModel.save(api: api)
+
+ XCTAssertNil(api.savedSetting)
+ XCTAssertEqual(viewModel.settingsTab, .notify)
+ XCTAssertTrue(viewModel.highlightedInvalidFieldKeys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue))
+ }
+
+ func testPresetVoicePlayStopAndDeleteState() async {
+ let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.presetPlayback")!
+ defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.presetPlayback")
+ let store = AppStore(defaults: defaults)
+ store.session.userId = "u1"
+ store.session.accountType = .unknown("photog")
+ store.session.currentScenicId = 11
+ store.scenicQueue.punchSpotId = "22"
+ store.scenicQueue.punchSpotName = "东门打卡点"
+ store.scenicQueue.savePresetVoices(["请到拍摄区域"])
+ let tts = MockScenicQueueTTSManager()
+ let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts)
+
+ viewModel.startPresetVoiceLoop("请到拍摄区域")
+ for _ in 0..<20 where viewModel.ttsLoopAnchorText == nil {
+ await Task.yield()
+ }
+ XCTAssertTrue(viewModel.isCustomTTSLooping)
+ XCTAssertEqual(viewModel.ttsLoopAnchorText, "请到拍摄区域")
+ XCTAssertTrue(tts.events.contains("custom:请到拍摄区域"))
+
+ viewModel.deletePresetVoice(at: 0)
+ XCTAssertTrue(viewModel.presetVoices.isEmpty)
+ XCTAssertFalse(viewModel.isCustomTTSLooping)
+ XCTAssertNil(viewModel.ttsLoopAnchorText)
+ XCTAssertEqual(tts.events.last, "stopCustom")
+ }
+
+ func testTicketActionStateMatchesAndroidCardMatrix() {
+ let waiting = makeTicket(status: .waiting, showCompleteAction: false)
+ let waitingState = ScenicQueueTicketActionState.make(ticket: waiting, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: true)
+ XCTAssertTrue(waitingState.skipEnabled)
+ XCTAssertEqual(waitingState.primaryTitle, "叫号")
+ XCTAssertTrue(waitingState.primaryVisible)
+ XCTAssertFalse(waitingState.recallVisible)
+ XCTAssertFalse(waitingState.completeVisible)
+
+ let called = makeTicket(status: .called, showCompleteAction: true)
+ let hiddenStartState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: nil, remainSeconds: 0, showStartShootingButton: false)
+ XCTAssertFalse(hiddenStartState.primaryVisible)
+ XCTAssertTrue(hiddenStartState.recallVisible)
+ XCTAssertTrue(hiddenStartState.completeVisible)
+
+ let shootingState = ScenicQueueTicketActionState.make(ticket: called, shootingQueueNo: called.queueNo, remainSeconds: 17, showStartShootingButton: true)
+ XCTAssertFalse(shootingState.skipEnabled)
+ XCTAssertFalse(shootingState.primaryEnabled)
+ XCTAssertEqual(shootingState.primaryTitle, "剩余 17 秒")
+ XCTAssertFalse(shootingState.recallVisible)
+ XCTAssertTrue(shootingState.completeVisible)
+ }
+
+ func testQueueVisualTokensAndChangeLogPresentationContract() {
+ XCTAssertEqual(ScenicQueueTokens.radiusCard, 12)
+ XCTAssertEqual(ScenicQueueTokens.radiusDialog, 16)
+ XCTAssertEqual(ScenicQueueTokens.radiusPrimaryRowButton, 10)
+ XCTAssertEqual(ScenicQueueTokens.ticketIdFont.pointSize, 20)
+ XCTAssertEqual(ScenicQueueTokens.dialogTicketFont.pointSize, 28)
+ XCTAssertEqual(ScenicQueueTokens.listHorizontalInset, 12)
+ XCTAssertTrue(ScenicQueueTokens.timelineDot.isEqualToRGB(0x1677FF))
+ XCTAssertTrue(ScenicQueueTokens.tagWaitingForeground.isEqualToRGB(0x10B981))
+
+ let distanceField = ScenicQueueNumericFieldView(key: "queueDistanceMeter", unit: "千米", keyboard: .decimalPad)
+ XCTAssertEqual(distanceField.subviews.compactMap { ($0 as? UILabel)?.text }, ["千米"])
+
+ let item = ScenicQueueSettingChangeLogItem(
+ id: 1,
+ scenicId: 11,
+ scenicSpotId: 22,
+ scenicSpotName: "东门打卡点",
+ operatorId: nil,
+ operatorName: " ",
+ operatorPhoneTail: "8000",
+ summary: " ",
+ displayText: "拍摄时长由 5 分钟改为 8 分钟",
+ createdAt: "2026-07-07T10:20:30+08:00"
+ )
+ XCTAssertEqual(item.metadataLine, "2026-07-07 10:20 未知 尾号8000")
+ XCTAssertEqual(item.cardSummary, "拍摄时长由 5 分钟改为 8 分钟")
+ }
+
+ func testPendingDialogDismissMethodsClearEveryOperationState() async {
+ let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.pendingDialogs")!
+ defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.pendingDialogs")
+ let store = AppStore(defaults: defaults)
+ store.session.userId = "9"
+ store.session.accountType = .unknown("photog")
+ store.session.currentScenicId = 11
+ store.scenicQueue.punchSpotId = "22"
+ store.scenicQueue.punchSpotName = "东门打卡点"
+ let api = MockScenicQueueAPI()
+ api.homePages[1] = .page(items: [.item(id: 1, queueCode: "A01", isCalled: 0)], total: 1, page: 1)
+ let viewModel = ScenicQueueViewModel(appStore: store, ttsManager: MockScenicQueueTTSManager())
+ await viewModel.refreshQueueGate(api: api)
+
+ viewModel.requestCallConfirm(recordId: 1, queueNo: "A01")
+ viewModel.requestSkipTicket(recordId: 1, queueNo: "A01")
+ viewModel.requestFinishConfirm(recordId: 1, queueNo: "A01")
+ viewModel.requestRequeueConfirm(recordId: 1, queueNo: "A01")
+ viewModel.onTicketMarkClick(recordId: 1, queueNo: "A01", uid: 1)
+ XCTAssertNotNil(viewModel.pendingCall)
+ XCTAssertNotNil(viewModel.pendingSkip)
+ XCTAssertNotNil(viewModel.pendingFinish)
+ XCTAssertNotNil(viewModel.pendingRequeue)
+ XCTAssertNotNil(viewModel.pendingMark)
+
+ viewModel.dismissCallConfirmDialog()
+ viewModel.dismissSkipDialog()
+ viewModel.dismissFinishDialog()
+ viewModel.dismissRequeueDialog()
+ viewModel.dismissMarkDialog()
+ XCTAssertNil(viewModel.pendingCall)
+ XCTAssertNil(viewModel.pendingSkip)
+ XCTAssertNil(viewModel.pendingFinish)
+ XCTAssertNil(viewModel.pendingRequeue)
+ XCTAssertNil(viewModel.pendingMark)
+ }
+
+ private func makeTicket(status: ScenicQueueCallStatus, showCompleteAction: Bool) -> ScenicQueueTicket {
+ ScenicQueueTicket(
+ recordId: 1,
+ queueNo: "A01",
+ phoneMasked: "138****8000",
+ dialPhoneDigits: "13800138000",
+ status: status,
+ statusText: status == .waiting ? "待叫号" : "已叫号",
+ waitMinutes: 5,
+ peopleAhead: 2,
+ showCompleteAction: showCompleteAction,
+ queueBanLabel: "",
+ identityTag: "",
+ queueTimeDisplay: "07-07 10:20",
+ queueCountToday: 1,
+ uid: 8,
+ markAsPhotog: 0,
+ markAsFreelancePhotog: 0,
+ isMissRequeue: false,
+ missRequeueText: ""
+ )
+ }
+}
+
+private extension UIColor {
+ func isEqualToRGB(_ rgb: UInt32) -> Bool {
+ var red: CGFloat = 0
+ var green: CGFloat = 0
+ var blue: CGFloat = 0
+ var alpha: CGFloat = 0
+ guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
+ let expectedRed = CGFloat((rgb >> 16) & 0xFF) / 255
+ let expectedGreen = CGFloat((rgb >> 8) & 0xFF) / 255
+ let expectedBlue = CGFloat(rgb & 0xFF) / 255
+ return abs(red - expectedRed) < 0.001 && abs(green - expectedGreen) < 0.001 && abs(blue - expectedBlue) < 0.001 && abs(alpha - 1) < 0.001
+ }
}
@MainActor
diff --git a/suixinkanTests/UIButtonConfigurationTests.swift b/suixinkanTests/UIButtonConfigurationTests.swift
index 0da4b89..407380e 100644
--- a/suixinkanTests/UIButtonConfigurationTests.swift
+++ b/suixinkanTests/UIButtonConfigurationTests.swift
@@ -68,4 +68,19 @@ final class UIButtonConfigurationTests: XCTestCase {
XCTAssertEqual(button.currentTitle, "更新标题")
XCTAssertEqual(button.currentTitleColor, .red)
}
+
+ func testImagePlacementPreservesExistingConfiguration() {
+ var configuration = UIButton.Configuration.plain()
+ configuration.title = "电话"
+ configuration.contentInsets = NSDirectionalEdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4)
+ let button = UIButton(configuration: configuration)
+
+ button.setConfigurationImagePlacement(.trailing, padding: 6)
+
+ XCTAssertEqual(button.configuration?.title, "电话")
+ XCTAssertEqual(button.configuration?.contentInsets.leading, 2)
+ XCTAssertEqual(button.configuration?.contentInsets.trailing, 4)
+ XCTAssertEqual(button.configuration?.imagePlacement, .trailing)
+ XCTAssertEqual(button.configuration?.imagePadding, 6)
+ }
}
diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift
index bbdcae4..dc18695 100644
--- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift
+++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift
@@ -800,6 +800,21 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(viewModel.reportLocation, "瘦西湖路")
}
+ func testSubmitTextInputUpdatesWithoutRequestingStructuralRefresh() {
+ let viewModel = WildPhotographerReportSubmitViewModel(
+ homeViewModel: WildPhotographerReportHomeViewModel()
+ )
+ var stateChangeCount = 0
+ viewModel.onStateChange = { stateChangeCount += 1 }
+
+ viewModel.updateDescription("现场说明")
+ viewModel.updateContact("wx-test")
+
+ XCTAssertEqual(viewModel.description, "现场说明")
+ XCTAssertEqual(viewModel.contact, "wx-test")
+ XCTAssertEqual(stateChangeCount, 0)
+ }
+
func testSubmitFailsWithoutEvidence() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)