完善景区排队设置页与全局按钮配置迁移。

重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 16:21:53 +08:00
co-authored by Cursor
parent efb3925257
commit 6336feff27
49 changed files with 1996 additions and 590 deletions
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "queue_location.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3C8.134 3 5 6.134 5 10C5 12.862 6.782 15.623 8.738 17.763C9.698 18.813 10.661 19.671 11.385 20.267C11.619 20.459 11.826 20.623 12 20.757C12.174 20.623 12.381 20.459 12.615 20.267C13.339 19.671 14.302 18.813 15.262 17.763C17.218 15.623 19 12.863 19 10C19 6.134 15.866 3 12 3ZM12 23.214L11.433 22.824L11.403 22.803C11.386 22.791 11.361 22.773 11.329 22.75C11.265 22.705 11.174 22.639 11.059 22.553C10.828 22.381 10.503 22.131 10.115 21.811C9.339 21.173 8.302 20.249 7.262 19.112C5.218 16.876 3 13.637 3 10C3 5.029 7.029 1 12 1C16.971 1 21 5.029 21 10C21 13.637 18.782 16.876 16.738 19.112C15.698 20.25 14.661 21.173 13.885 21.811C13.497 22.131 13.172 22.381 12.941 22.553C12.826 22.639 12.735 22.705 12.671 22.75L12.597 22.803L12 23.214ZM12 8C10.895 8 10 8.895 10 10C10 11.105 10.895 12 12 12C13.105 12 14 11.105 14 10C14 8.895 13.105 8 12 8ZM8 10C8 7.791 9.791 6 12 6C14.209 6 16 7.791 16 10C16 12.209 14.209 14 12 14C9.791 14 8 12.209 8 10Z" fill="#0066FF"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -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]
@@ -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
@@ -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()
}
@@ -47,6 +47,7 @@ final class ScenicQueueSettingsViewModel {
private(set) var presetVoices: [String]
private(set) var settingsTab: ScenicQueueSettingsTab = .basic
private(set) var highlightedInvalidFieldKeys: Set<String> = []
private(set) var blockingInvalidNumericFieldKeys: Set<String> = []
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
@@ -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()
}
/// 添加本地现场图片。
@@ -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)
@@ -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
}
}
@@ -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)
}
@@ -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)
@@ -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)
}
@@ -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)
@@ -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)
}
+1 -1
View File
@@ -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()
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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<Int, ScenicQueueSettingChangeLogItem>!
@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<Int, ScenicQueueSettingChangeLogItem>(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
}
@objc private func refreshPulled() {
Task { await viewModel.refresh(api: api) }
viewModel.initialLoading && viewModel.items.isEmpty ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating()
emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
var snapshot = NSDiffableDataSourceSnapshot<Int, ScenicQueueSettingChangeLogItem>()
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
}
}
extension ScenicQueueSettingChangeLogViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.items.count
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
}
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
@objc private func refreshPulled() { Task { await viewModel.refresh(api: api) } }
}
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
}
}
File diff suppressed because it is too large Load Diff
@@ -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<Int, ScenicQueueListItem>!
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<Int, ScenicQueueListItem>(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<Int, ScenicQueueListItem>()
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"
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) }
}
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)
)
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) }
}
@@ -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) }
}
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -18,8 +18,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
private let emptyView = TravelAlbumEmptyView()
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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) {
@@ -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)
@@ -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)
}
@@ -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
@@ -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)
}
}
@@ -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)