diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index 8d228aa..c8d036e 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -18,6 +18,7 @@ final class NetworkServices { let homeAPI: HomeAPI let paymentAPI: PaymentAPI let taskAPI: TaskAPI + let scenicQueueAPI: ScenicQueueAPI let uploadAPI: UploadAPI let ossUploadService: OSSUploadService @@ -31,6 +32,7 @@ final class NetworkServices { homeAPI = HomeAPI(client: client) paymentAPI = PaymentAPI(client: client) taskAPI = TaskAPI(client: client) + scenicQueueAPI = ScenicQueueAPI(client: client) uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { @@ -49,6 +51,7 @@ final class NetworkServices { homeAPI = HomeAPI(client: apiClient) paymentAPI = PaymentAPI(client: apiClient) taskAPI = TaskAPI(client: apiClient) + scenicQueueAPI = ScenicQueueAPI(client: apiClient) uploadAPI = UploadAPI(client: apiClient) ossUploadService = OSSUploadService(configService: uploadAPI) } diff --git a/suixinkan/DataStore/AppStore.swift b/suixinkan/DataStore/AppStore.swift index 8c8c427..e572726 100644 --- a/suixinkan/DataStore/AppStore.swift +++ b/suixinkan/DataStore/AppStore.swift @@ -34,6 +34,12 @@ final class AppStore { static let lastLocationReportTime = "key_last_location_report_time" static let locationReminderMinutes = "key_location_reminder_minutes" static let isOpenReceiveVoice = "key_is_open_receive_voice" + static let scenicQueuePunchSpotId = "_scenic_queue_punch_spot_id" + static let scenicQueuePunchSpotName = "_scenic_queue_punch_spot_name" + static let scenicQueueRemark = "_scenic_queue_remark" + static let scenicQueueSettingsSnapshot = "_scenic_queue_settings_snapshot" + static let scenicQueueOfflineTTS = "_scenic_queue_offline_tts_v1" + static let scenicQueuePresetVoices = "_scenic_queue_preset_voices_v1" } private let defaults: UserDefaults @@ -161,6 +167,30 @@ final class AppStore { set { defaults.set(newValue, forKey: accountScopedKey(Key.isOpenReceiveVoice)) } } + /// 排队管理已保存打卡点 ID。 + var scenicQueuePunchSpotId: String { + get { defaults.string(forKey: accountScopedKey(Key.scenicQueuePunchSpotId)) ?? "" } + set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueuePunchSpotId)) } + } + + /// 排队管理已保存打卡点名称。 + var scenicQueuePunchSpotName: String { + get { defaults.string(forKey: accountScopedKey(Key.scenicQueuePunchSpotName)) ?? "" } + set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueuePunchSpotName)) } + } + + /// 排队管理自定义播报文案。 + var scenicQueueRemark: String { + get { defaults.string(forKey: accountScopedKey(Key.scenicQueueRemark)) ?? "" } + set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueueRemark)) } + } + + /// 排队语音是否优先使用离线 TTS。 + var scenicQueueUseOfflineTTS: Bool { + get { defaults.bool(forKey: accountScopedKey(Key.scenicQueueOfflineTTS)) } + set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueueOfflineTTS)) } + } + /// 当前账号缓存前缀,对齐 Android `getAccountCachePrefix`。 var accountCachePrefix: String { let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -306,6 +336,50 @@ final class AppStore { defaults.removeObject(forKey: accountScopedKey(Key.lastLocationReportTime)) } + /// 是否已经保存排队打卡点。 + func hasScenicQueuePunchSpotSaved() -> Bool { + let id = scenicQueuePunchSpotId.trimmingCharacters(in: .whitespacesAndNewlines) + return Int64(id).map { $0 > 0 } ?? false + } + + /// 保存排队设置快照。 + func saveScenicQueueSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot) { + guard let data = try? JSONEncoder().encode(snapshot) else { return } + defaults.set(data, forKey: accountScopedKey(Key.scenicQueueSettingsSnapshot)) + } + + /// 读取排队设置快照。 + func scenicQueueSettingsSnapshot() -> ScenicQueueSettingsSnapshot? { + guard let data = defaults.data(forKey: accountScopedKey(Key.scenicQueueSettingsSnapshot)) else { return nil } + return try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data) + } + + /// 保存排队预设播报文案,最多 5 条。 + func saveScenicQueuePresetVoices(_ voices: [String]) { + let normalized = voices + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(5) + defaults.set(Array(normalized), forKey: accountScopedKey(Key.scenicQueuePresetVoices)) + } + + /// 读取排队预设播报文案。 + func scenicQueuePresetVoices() -> [String] { + defaults.stringArray(forKey: accountScopedKey(Key.scenicQueuePresetVoices)) ?? [] + } + + /// 清除排队管理本地缓存。 + func clearScenicQueueSnapshot() { + [ + Key.scenicQueuePunchSpotId, + Key.scenicQueuePunchSpotName, + Key.scenicQueueRemark, + Key.scenicQueueSettingsSnapshot, + Key.scenicQueueOfflineTTS, + Key.scenicQueuePresetVoices, + ].forEach { defaults.removeObject(forKey: accountScopedKey($0)) } + } + /// 清除权限与位置会话快照。 func clearPermissionSnapshot() { let prefix = accountScopedKey("") @@ -317,6 +391,12 @@ final class AppStore { Key.lastLocationReportTime, Key.locationReminderMinutes, Key.isOpenReceiveVoice, + Key.scenicQueuePunchSpotId, + Key.scenicQueuePunchSpotName, + Key.scenicQueueRemark, + Key.scenicQueueSettingsSnapshot, + Key.scenicQueueOfflineTTS, + Key.scenicQueuePresetVoices, ] keys.forEach { defaults.removeObject(forKey: prefix + $0) } } diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index e67b953..cb5095b 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -50,6 +50,8 @@ enum HomeRouteHandler { viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true) case "location_report": viewController.navigationController?.pushViewController(LocationReportViewController(), animated: true) + case "/scenic-queue": + viewController.navigationController?.pushViewController(ScenicQueueViewController(), animated: true) case "task_management", "task_management_editor": viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true) default: diff --git a/suixinkan/Features/ScenicQueue/API/ScenicQueueAPI.swift b/suixinkan/Features/ScenicQueue/API/ScenicQueueAPI.swift new file mode 100644 index 0000000..bdb2beb --- /dev/null +++ b/suixinkan/Features/ScenicQueue/API/ScenicQueueAPI.swift @@ -0,0 +1,215 @@ +// +// ScenicQueueAPI.swift +// suixinkan +// + +import Foundation + +@MainActor +/// 排队管理 API 协议,便于 ViewModel 单元测试注入替身。 +protocol ScenicQueueAPIProtocol { + func saveSetting(_ request: ScenicQueueSaveSettingRequest) async throws + func call(recordId: Int64) async throws -> ScenicQueueCallData + func pass(recordId: Int64) async throws -> ScenicQueuePassData + func finish(recordId: Int64) async throws -> ScenicQueueFinishData + func userMark(_ request: ScenicQueueUserMarkRequest) async throws + func requeueInsertBefore(recordId: Int64, operatorId: Int) async throws + func shootQueueQrcode(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueShootQueueQrcodeData + func setting(scenicId: Int64?, scenicSpotId: Int64?) async throws -> ScenicQueueSettingData + func stats(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueStatsData + func home(scenicId: Int64, scenicSpotId: Int64, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData + func settingChangeLog(scenicId: Int64, scenicSpotId: Int64?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData + func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse + func socketToken() async throws -> SocketTokenResponse + func aliyunSTS(bucket: String) async throws -> AliyunOSSResponse +} + +@MainActor +/// 排队管理 API,封装 Android `NetworkApi` 中 scenic-queue 相关接口。 +final class ScenicQueueAPI: ScenicQueueAPIProtocol { + private let client: APIClient + + init(client: APIClient) { + self.client = client + } + + /// 保存排队设置。 + func saveSetting(_ request: ScenicQueueSaveSettingRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/save-setting", body: request) + ) + } + + /// 叫号。 + func call(recordId: Int64) async throws -> ScenicQueueCallData { + try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/call", body: ScenicQueueIdRequest(id: recordId)) + ) + } + + /// 过号。 + func pass(recordId: Int64) async throws -> ScenicQueuePassData { + try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/pass", body: ScenicQueueIdRequest(id: recordId)) + ) + } + + /// 完成拍摄。 + func finish(recordId: Int64) async throws -> ScenicQueueFinishData { + try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/finish", body: ScenicQueueIdRequest(id: recordId)) + ) + } + + /// 标记用户。 + func userMark(_ request: ScenicQueueUserMarkRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/user-mark", body: request) + ) + } + + /// 将过号记录插入队首后指定位置。 + func requeueInsertBefore(recordId: Int64, operatorId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/scenic-queue/requeue-insert-before", + body: ScenicQueueRequeueInsertBeforeRequest(recordId: recordId, operatorId: operatorId) + ) + ) + } + + /// 获取打卡点排队二维码。 + func shootQueueQrcode(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueShootQueueQrcodeData { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-queue/shoot-queue-qrcode", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)), + ] + ) + ) + } + + /// 获取指定打卡点设置。 + func setting(scenicId: Int64?, scenicSpotId: Int64?) async throws -> ScenicQueueSettingData { + var items: [URLQueryItem] = [] + if let scenicId { + items.append(URLQueryItem(name: "scenic_id", value: String(scenicId))) + } + if let scenicSpotId { + items.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))) + } + return try await client.send( + APIRequest(method: .get, path: "/api/app/scenic-queue/setting", queryItems: items) + ) + } + + /// 获取排队统计。 + func stats(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueStatsData { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-queue/queue-stats", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)), + ] + ) + ) + } + + /// 获取排队首页列表。 + func home(scenicId: Int64, scenicSpotId: Int64, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-queue/home", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)), + URLQueryItem(name: "type", value: String(type)), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)), + ] + ) + ) + } + + /// 获取配置变更日志。 + func settingChangeLog(scenicId: Int64, scenicSpotId: Int64?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData { + var items = [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)), + ] + if let scenicSpotId { + items.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))) + } + return try await client.send( + APIRequest(method: .get, path: "/api/app/scenic-queue/setting-change-log", queryItems: items) + ) + } + + /// 获取当前景区全部打卡点。 + func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic-spot/list-all", + queryItems: [URLQueryItem(name: "scenic_id", value: scenicId)] + ) + ) + } + + /// 获取业务 WebSocket token。 + func socketToken() async throws -> SocketTokenResponse { + try await client.send(APIRequest(method: .get, path: "/api/app/socket-token")) + } + + /// 获取阿里云 STS 临时凭证。 + func aliyunSTS(bucket: String = "vipsky") async throws -> AliyunOSSResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/config/get-sts-token", + queryItems: [URLQueryItem(name: "bucket", value: bucket)] + ) + ) + } +} + +/// 仅包含 id 的排队操作请求体。 +struct ScenicQueueIdRequest: Encodable, Equatable { + let id: Int64 +} + +/// 过号重排请求体。 +struct ScenicQueueRequeueInsertBeforeRequest: Encodable, Equatable { + let recordId: Int64 + let operatorId: Int + + enum CodingKeys: String, CodingKey { + case recordId = "record_id" + case operatorId = "operator_id" + } +} + +/// 用户标记请求体。 +struct ScenicQueueUserMarkRequest: Encodable, Equatable { + let uid: Int64 + let scenicId: Int64 + let markAsFreelancePhotog: Int + let queueBanDays: Int? + let operatorId: Int? + + enum CodingKeys: String, CodingKey { + case uid + case scenicId = "scenic_id" + case markAsFreelancePhotog = "mark_as_freelance_photog" + case queueBanDays = "queue_ban_days" + case operatorId = "operator_id" + } +} diff --git a/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift b/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift new file mode 100644 index 0000000..36caf56 --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift @@ -0,0 +1,466 @@ +// +// ScenicQueueAPIModels.swift +// suixinkan +// + +import Foundation + +/// GET /api/app/scenic-queue/queue-stats 响应。 +struct ScenicQueueStatsData: Decodable, Equatable { + let queueCount: Int + let avgWaitMin: Double + let time: String? + + enum CodingKeys: String, CodingKey { + case queueCount = "queue_count" + case avgWaitMin = "avg_wait_min" + case time + } +} + +/// GET /api/app/scenic-queue/home 响应。 +struct ScenicQueueHomeData: Decodable, Equatable { + let stats: ScenicQueueHomeStats? + let list: ScenicQueueHomeListBlock? + let time: String? +} + +/// 排队 home 接口 stats 节点。 +struct ScenicQueueHomeStats: Decodable, Equatable { + let type: Int +} + +/// 排队 home 接口分页列表节点。 +struct ScenicQueueHomeListBlock: Decodable, Equatable { + let list: [ScenicQueueHomeItem] + let total: Int + let page: Int + let pageSize: Int + + enum CodingKeys: String, CodingKey { + case list + case total + case page + case pageSize = "page_size" + } +} + +/// 排队 home 接口单条记录。 +struct ScenicQueueHomeItem: Decodable, Equatable { + let id: Int64 + let queueCode: String + let mobile: String + let status: Int + let statusText: String + let waitMin: Int + let aheadCount: Int + let isCalled: Int + let createdAt: String? + let calledAt: String? + let expiredAt: String? + let finishedAt: String? + let identityTag: String? + let queueBanLabel: String? + let queueBanDays: Int? + let queueTime: String? + let queueCountToday: Int? + let uid: Int64 + let markAsPhotog: Int + let markAsFreelancePhotog: Int + let isMissRequeue: Int + let isMissRequeueText: String? + + enum CodingKeys: String, CodingKey { + case id + case queueCode = "queue_code" + case mobile + case status + case statusText = "status_text" + case waitMin = "wait_min" + case aheadCount = "ahead_count" + case isCalled = "is_called" + case createdAt = "created_at" + case calledAt = "called_at" + case expiredAt = "expired_at" + case finishedAt = "finished_at" + case identityTag = "identity_tag" + case queueBanLabel = "queue_ban_label" + case queueBanDays = "queue_ban_days" + case queueTime = "queue_time" + case queueCountToday = "queue_count_today" + case uid + case markAsPhotog = "mark_as_photog" + case markAsFreelancePhotog = "mark_as_freelance_photog" + case isMissRequeue = "is_miss_requeue" + case isMissRequeueText = "is_miss_requeue_text" + } +} + +extension ScenicQueueHomeItem { + /// 转换为当前排队展示实体。 + func toCurrentTicket() -> ScenicQueueTicket { + ScenicQueueTicket( + recordId: id, + queueNo: queueCode, + phoneMasked: Self.maskMobile(mobile), + dialPhoneDigits: mobile.filter(\.isNumber), + status: isCalled == 1 || statusText.contains("已叫") ? .called : .waiting, + statusText: statusText.isEmpty ? (isCalled == 1 ? "已叫号" : "待叫号") : statusText, + waitMinutes: max(0, waitMin), + peopleAhead: max(0, aheadCount), + showCompleteAction: isCalled == 1 || statusText.contains("已叫"), + queueBanLabel: queueBanLabel ?? "", + identityTag: identityTag ?? "", + queueTimeDisplay: Self.formatQueueTime(queueTime ?? createdAt), + queueCountToday: queueCountToday ?? 0, + uid: uid, + markAsPhotog: markAsPhotog, + markAsFreelancePhotog: markAsFreelancePhotog, + isMissRequeue: isMissRequeue == 1, + missRequeueText: isMissRequeueText ?? "" + ) + } + + /// 转换为过号列表展示实体。 + func toSkippedTicket() -> ScenicQueueSkippedTicket { + ScenicQueueSkippedTicket( + recordId: id, + queueNo: queueCode, + phoneMasked: Self.maskMobile(mobile), + dialPhoneDigits: mobile.filter(\.isNumber), + statusText: statusText.isEmpty ? "已过号" : statusText, + skippedTimeDisplay: Self.formatQueueTime(expiredAt ?? finishedAt ?? calledAt), + queueBanLabel: queueBanLabel ?? "", + isMissRequeue: isMissRequeue == 1, + missRequeueText: isMissRequeueText ?? "" + ) + } + + private static func maskMobile(_ raw: String) -> String { + let digits = raw.filter(\.isNumber) + guard digits.count >= 7 else { return raw } + let start = digits.prefix(3) + let end = digits.suffix(4) + return "\(start)****\(end)" + } + + private static func formatQueueTime(_ raw: String?) -> String { + let text = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !text.isEmpty else { return "--" } + let normalized = text.replacingOccurrences(of: "T", with: " ").replacingOccurrences(of: "Z", with: "") + let inputFormats = ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"] + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + for format in inputFormats { + formatter.dateFormat = format + if let date = formatter.date(from: normalized) { + formatter.dateFormat = "MM-dd HH:mm" + return formatter.string(from: date) + } + } + return String(text.prefix(16)) + } +} + +/// POST /api/app/scenic-queue/call 响应 data。 +struct ScenicQueueCallData: Decodable, Equatable { + let id: Int64 + let status: Int + let statusText: String + let calledAt: String? + + enum CodingKeys: String, CodingKey { + case id + case status + case statusText = "status_text" + case calledAt = "called_at" + } +} + +/// POST /api/app/scenic-queue/pass 响应 data。 +struct ScenicQueuePassData: Decodable, Equatable { + let id: Int64 + let status: Int + let statusText: String + + enum CodingKeys: String, CodingKey { + case id + case status + case statusText = "status_text" + } +} + +/// POST /api/app/scenic-queue/finish 响应 data。 +struct ScenicQueueFinishData: Decodable, Equatable { + let id: Int64 + let status: Int + let statusText: String + + enum CodingKeys: String, CodingKey { + case id + case status + case statusText = "status_text" + } +} + +/// GET /api/app/scenic-queue/setting 响应。 +struct ScenicQueueSettingData: Decodable, Equatable { + let exists: Bool + let setting: ScenicQueueSettingItem? + let time: String? +} + +/// 服务端排队设置条目。 +struct ScenicQueueSettingItem: Codable, Equatable { + let id: Int64? + let scenicId: Int64? + let scenicSpotId: Int64? + let scenicSpotName: String? + let photoEstimateMin: Int? + let photoEstimateSec: Int? + let firstNoticeThresholdPos: Int? + let firstNoticeSmsEnabled: Int? + let firstNoticeCallEnabled: Int? + let secondNoticeThresholdPos: Int? + let secondNoticeSmsEnabled: Int? + let secondNoticeCallEnabled: Int? + let countdownBroadcastIntervalSec: Int? + let countdownReadableThresholdSec: Int? + let queueDistanceMeter: Int? + let queueTakeLimit: Int? + let missCallRequeueOffset: Int? + let showStartShootButton: Int? + let autoCallNextCount: Int? + let businessStartTime: String? + let businessEndTime: String? + let voiceBroadcasts: [ScenicQueueVoiceBroadcastItem]? + let status: Int? + let remark: String? + let createdAt: String? + let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case scenicId = "scenic_id" + case scenicSpotId = "scenic_spot_id" + case scenicSpotName = "scenic_spot_name" + case photoEstimateMin = "photo_estimate_min" + case photoEstimateSec = "photo_estimate_sec" + case firstNoticeThresholdPos = "first_notice_threshold_pos" + case firstNoticeSmsEnabled = "first_notice_sms_enabled" + case firstNoticeCallEnabled = "first_notice_call_enabled" + case secondNoticeThresholdPos = "second_notice_threshold_pos" + case secondNoticeSmsEnabled = "second_notice_sms_enabled" + case secondNoticeCallEnabled = "second_notice_call_enabled" + case countdownBroadcastIntervalSec = "countdown_broadcast_interval_sec" + case countdownReadableThresholdSec = "countdown_readable_threshold_sec" + case queueDistanceMeter = "queue_distance_meter" + case queueTakeLimit = "queue_take_limit" + case missCallRequeueOffset = "miss_call_requeue_offset" + case showStartShootButton = "show_start_shoot_button" + case autoCallNextCount = "auto_call_next_count" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + case voiceBroadcasts = "voice_broadcasts" + case status + case remark + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +extension ScenicQueueSettingItem { + /// 转换为本地设置快照。 + func toSnapshot(fallback: ScenicQueueSettingsSnapshot = ScenicQueueSettingsSnapshot()) -> ScenicQueueSettingsSnapshot { + ScenicQueueSettingsSnapshot( + shootMinute: (photoEstimateMin ?? 0).clamped(to: 0...30), + shootSecond: (photoEstimateSec ?? 0).clamped(to: 0...59), + firstAheadCount: (firstNoticeThresholdPos ?? 0).clamped(to: 0...50), + firstSms: (firstNoticeSmsEnabled ?? 0) == 1, + firstPhone: (firstNoticeCallEnabled ?? 0) == 1, + secondAheadCount: (secondNoticeThresholdPos ?? 0).clamped(to: 0...50), + secondSms: (secondNoticeSmsEnabled ?? 0) == 1, + secondPhone: (secondNoticeCallEnabled ?? 0) == 1, + broadcastIntervalSec: ScenicQueueSettingItem.normalizedBroadcastInterval(countdownBroadcastIntervalSec), + countdownThresholdSec: ScenicQueueSettingItem.normalizedReadableThreshold(countdownReadableThresholdSec), + queueDistanceMeter: (queueDistanceMeter ?? 0).clamped(to: 0...9_999_990), + queueTakeLimit: (queueTakeLimit ?? 0).clamped(to: 0...999_999), + missCallRequeueOffset: (missCallRequeueOffset ?? 1).clamped(to: 1...9_999), + showStartShootingButton: showStartShootButton.map { $0 == 1 } ?? fallback.showStartShootingButton, + autoCallAheadCount: (autoCallNextCount ?? fallback.autoCallAheadCount).clamped(to: 0...5), + quickCallButtonEnabled: fallback.quickCallButtonEnabled, + prepareCallButtonEnabled: fallback.prepareCallButtonEnabled, + businessOpen: (status ?? 1) == 1, + businessStartTime: businessStartTime?.nonEmptyTrimmed ?? "10:00", + businessEndTime: businessEndTime?.nonEmptyTrimmed ?? "20:00" + ) + } + + private static func normalizedBroadcastInterval(_ value: Int?) -> Int { + guard let value, 40...60 ~= value else { return 50 } + return value + } + + private static func normalizedReadableThreshold(_ value: Int?) -> Int { + guard let value, 10...30 ~= value else { return 15 } + return value + } +} + +/// POST /api/app/scenic-queue/save-setting 请求体。 +struct ScenicQueueSaveSettingRequest: Encodable, Equatable { + let scenicId: Int64 + let scenicSpotId: Int64 + let photoEstimateMin: Int? + let photoEstimateSec: Int? + let firstNoticeThresholdPos: Int? + let firstNoticeSmsEnabled: Int? + let firstNoticeCallEnabled: Int? + let secondNoticeThresholdPos: Int? + let secondNoticeSmsEnabled: Int? + let secondNoticeCallEnabled: Int? + let countdownBroadcastIntervalSec: Int? + let countdownReadableThresholdSec: Int? + let queueDistanceMeter: Int? + let queueTakeLimit: Int? + let missCallRequeueOffset: Int? + let showStartShootButton: Int? + let autoCallNextCount: Int? + let businessStartTime: String? + let businessEndTime: String? + let voiceBroadcasts: [ScenicQueueVoiceBroadcastItem] + let status: Int? + let remark: String? + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case scenicSpotId = "scenic_spot_id" + case photoEstimateMin = "photo_estimate_min" + case photoEstimateSec = "photo_estimate_sec" + case firstNoticeThresholdPos = "first_notice_threshold_pos" + case firstNoticeSmsEnabled = "first_notice_sms_enabled" + case firstNoticeCallEnabled = "first_notice_call_enabled" + case secondNoticeThresholdPos = "second_notice_threshold_pos" + case secondNoticeSmsEnabled = "second_notice_sms_enabled" + case secondNoticeCallEnabled = "second_notice_call_enabled" + case countdownBroadcastIntervalSec = "countdown_broadcast_interval_sec" + case countdownReadableThresholdSec = "countdown_readable_threshold_sec" + case queueDistanceMeter = "queue_distance_meter" + case queueTakeLimit = "queue_take_limit" + case missCallRequeueOffset = "miss_call_requeue_offset" + case showStartShootButton = "show_start_shoot_button" + case autoCallNextCount = "auto_call_next_count" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + case voiceBroadcasts = "voice_broadcasts" + case status + case remark + } +} + +/// 排队语音播报预设条目。 +struct ScenicQueueVoiceBroadcastItem: Codable, Equatable { + let content: String? + let sortOrder: Int? + + enum CodingKeys: String, CodingKey { + case content + case sortOrder = "sort_order" + } +} + +/// GET /api/app/scenic-queue/shoot-queue-qrcode 响应。 +struct ScenicQueueShootQueueQrcodeData: Decodable, Equatable { + let qrcodeUrl: String? + let scene: String? + let page: String? + let time: String? + + enum CodingKeys: String, CodingKey { + case qrcodeUrl = "qrcode_url" + case scene + case page + case time + } +} + +/// GET /api/app/scenic-queue/setting-change-log 响应。 +struct ScenicQueueSettingChangeLogData: Decodable, Equatable { + let total: Int + let page: Int + let pageSize: Int + let list: [ScenicQueueSettingChangeLogItem] + + enum CodingKeys: String, CodingKey { + case total + case page + case pageSize = "page_size" + case list + } +} + +/// 配置变更日志条目。 +struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Hashable { + let id: Int64 + let scenicId: Int? + let scenicSpotId: Int? + let scenicSpotName: String + let operatorId: Int? + let operatorName: String + let operatorPhoneTail: String + let summary: String + let displayText: String + let createdAt: String? + + enum CodingKeys: String, CodingKey { + case id + case scenicId = "scenic_id" + case scenicSpotId = "scenic_spot_id" + case scenicSpotName = "scenic_spot_name" + case operatorId = "operator_id" + case operatorName = "operator_name" + case operatorPhoneTail = "operator_phone_tail" + case summary + case displayText = "display_text" + case createdAt = "created_at" + } +} + +/// 打卡点列表响应。 +struct ScenicSpotListResponse: Decodable, Equatable { + let list: [ScenicSpotItem] + let total: Int? + + enum CodingKeys: String, CodingKey { + case list + case total + } +} + +/// 打卡点列表条目。 +struct ScenicSpotItem: Decodable, Equatable { + let id: Int64 + let name: String +} + +/// Socket token 响应。 +struct SocketTokenResponse: Decodable, Equatable { + let socketToken: String + + enum CodingKeys: String, CodingKey { + case socketToken = "socket_token" + } +} + +private extension Int { + func clamped(to range: ClosedRange) -> Int { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} + +private extension String { + var nonEmptyTrimmed: String? { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift b/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift new file mode 100644 index 0000000..5d70f76 --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift @@ -0,0 +1,177 @@ +// +// ScenicQueueModels.swift +// suixinkan +// + +import Foundation + +/// 当前排队列表中的叫号状态。 +enum ScenicQueueCallStatus: Equatable { + case waiting + case called +} + +/// 当前排队列表展示实体。 +struct ScenicQueueTicket: Hashable { + let recordId: Int64 + let queueNo: String + let phoneMasked: String + let dialPhoneDigits: String + let status: ScenicQueueCallStatus + let statusText: String + let waitMinutes: Int + let peopleAhead: Int + let showCompleteAction: Bool + let queueBanLabel: String + let identityTag: String + let queueTimeDisplay: String + let queueCountToday: Int + let uid: Int64 + let markAsPhotog: Int + let markAsFreelancePhotog: Int + let isMissRequeue: Bool + let missRequeueText: String + + /// 是否展示身份角标,普通用户不展示。 + var shouldShowIdentityTag: Bool { + let text = identityTag.trimmingCharacters(in: .whitespacesAndNewlines) + return !text.isEmpty && text != "普通用户" + } + + /// 返回叫号后本地更新后的副本。 + func markedCalled(statusText newStatusText: String) -> ScenicQueueTicket { + ScenicQueueTicket( + recordId: recordId, + queueNo: queueNo, + phoneMasked: phoneMasked, + dialPhoneDigits: dialPhoneDigits, + status: .called, + statusText: newStatusText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? statusText : newStatusText, + waitMinutes: waitMinutes, + peopleAhead: peopleAhead, + showCompleteAction: true, + queueBanLabel: queueBanLabel, + identityTag: identityTag, + queueTimeDisplay: queueTimeDisplay, + queueCountToday: queueCountToday, + uid: uid, + markAsPhotog: markAsPhotog, + markAsFreelancePhotog: markAsFreelancePhotog, + isMissRequeue: isMissRequeue, + missRequeueText: missRequeueText + ) + } +} + +/// 过号列表展示实体。 +struct ScenicQueueSkippedTicket: Hashable { + let recordId: Int64 + let queueNo: String + let phoneMasked: String + let dialPhoneDigits: String + let statusText: String + let skippedTimeDisplay: String + let queueBanLabel: String + let isMissRequeue: Bool + let missRequeueText: String +} + +/// 排队页顶部统计。 +struct ScenicQueueSummary: Equatable { + let peopleInQueue: Int + let avgWaitMinutes: Int + + static let empty = ScenicQueueSummary(peopleInQueue: 0, avgWaitMinutes: 0) +} + +/// 打卡点下拉选项。 +struct ScenicQueuePunchSpotOption: Hashable { + let id: String + let label: String +} + +/// 排队设置本地快照,用于快速恢复 UI 与叫号配置。 +struct ScenicQueueSettingsSnapshot: Codable, Equatable { + var shootMinute: Int = 0 + var shootSecond: Int = 0 + var firstAheadCount: Int = 0 + var firstSms: Bool = false + var firstPhone: Bool = false + var secondAheadCount: Int = 0 + var secondSms: Bool = false + var secondPhone: Bool = false + var broadcastIntervalSec: Int = 50 + var countdownThresholdSec: Int = 15 + var queueDistanceMeter: Int = 0 + var queueTakeLimit: Int = 0 + var missCallRequeueOffset: Int = 1 + var showStartShootingButton: Bool = true + var autoCallAheadCount: Int = 0 + var quickCallButtonEnabled: Bool = false + var prepareCallButtonEnabled: Bool = false + var businessOpen: Bool = true + var businessStartTime: String = "10:00" + var businessEndTime: String = "20:00" +} + +/// 待确认的过号操作。 +struct ScenicQueuePendingSkip: Equatable { + let recordId: Int64 + let queueNo: String +} + +/// 待确认的重新排队操作。 +struct ScenicQueuePendingRequeue: Equatable { + let recordId: Int64 + let queueNo: String +} + +/// 待确认的完成操作。 +struct ScenicQueuePendingFinish: Equatable { + let recordId: Int64 + let queueNo: String +} + +/// 待确认的叫号操作。 +struct ScenicQueuePendingCall: Equatable { + let recordId: Int64 + let queueNo: String + let isRecall: Bool +} + +/// 待确认的用户标记操作。 +struct ScenicQueuePendingMark: Equatable { + let uid: Int64 + let queueNo: String + let phoneMasked: String + let markAsFreelancePhotog: Int +} + +/// 排队模块数字字段键,用于校验定位。 +enum ScenicQueueNumericFieldKey: String { + case queueDistanceMeter + case queueTakeLimit + case missCallRequeueOffset + case autoCallAheadCount + case shootMinute + case shootSecond + case firstNoticeAhead + case secondNoticeAhead + case broadcastIntervalSec + case countdownReadableThresholdSec +} + +/// 排队设置页 Tab。 +enum ScenicQueueSettingsTab: Int, CaseIterable { + case basic + case notify + case voice + + var title: String { + switch self { + case .basic: "基础配置" + case .notify: "通知配置" + case .voice: "语音播报" + } + } +} diff --git a/suixinkan/Features/ScenicQueue/Models/ScenicQueueSocketModels.swift b/suixinkan/Features/ScenicQueue/Models/ScenicQueueSocketModels.swift new file mode 100644 index 0000000..63d9bbd --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Models/ScenicQueueSocketModels.swift @@ -0,0 +1,45 @@ +// +// ScenicQueueSocketModels.swift +// suixinkan +// + +import Foundation + +/// 排队 WebSocket 消息。 +struct ScenicQueueSocketMessage: Decodable, Equatable { + let code: Int + let data: ScenicQueueSocketData? + + /// 是否为排队更新事件。 + var isQueueEvent: Bool { + data?.action == ScenicQueueSocketAction.queueUpdated.rawValue + || data?.action == ScenicQueueSocketAction.ticketCalled.rawValue + } +} + +/// 排队 WebSocket data 节点。 +struct ScenicQueueSocketData: Decodable, Equatable { + let action: String + let params: ScenicQueueSocketParams? +} + +/// 排队 WebSocket params 节点。 +struct ScenicQueueSocketParams: Decodable, Equatable { + let scenicSpotId: Int64? + let recordId: Int64? + let operatorUid: Int64? + let eventId: String? + + enum CodingKeys: String, CodingKey { + case scenicSpotId = "scenic_spot_id" + case recordId = "record_id" + case operatorUid = "operator_uid" + case eventId = "event_id" + } +} + +/// 排队 WebSocket action。 +enum ScenicQueueSocketAction: String { + case queueUpdated = "scenic_spot_queue_updated" + case ticketCalled = "scenic_queue_ticket_called" +} diff --git a/suixinkan/Features/ScenicQueue/Services/ScenicQueueQRCodeSaver.swift b/suixinkan/Features/ScenicQueue/Services/ScenicQueueQRCodeSaver.swift new file mode 100644 index 0000000..8de5666 --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Services/ScenicQueueQRCodeSaver.swift @@ -0,0 +1,55 @@ +// +// ScenicQueueQRCodeSaver.swift +// suixinkan +// + +import Foundation +import Photos +import UIKit + +/// 排队二维码保存服务。 +enum ScenicQueueQRCodeSaver { + /// 下载二维码图片并保存到系统相册。 + static func saveImage(from urlString: String) async throws { + guard let url = URL(string: urlString) else { + throw ScenicQueueQRCodeSaverError.invalidURL + } + let (data, _) = try await URLSession.shared.data(from: url) + guard let image = UIImage(data: data) else { + throw ScenicQueueQRCodeSaverError.invalidImage + } + try await saveToPhotoLibrary(image) + } + + private static func saveToPhotoLibrary(_ image: UIImage) async throws { + let status = PHPhotoLibrary.authorizationStatus(for: .addOnly) + if status == .notDetermined { + _ = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + } + guard PHPhotoLibrary.authorizationStatus(for: .addOnly) == .authorized + || PHPhotoLibrary.authorizationStatus(for: .addOnly) == .limited else { + throw ScenicQueueQRCodeSaverError.denied + } + try await PHPhotoLibrary.shared().performChanges { + PHAssetChangeRequest.creationRequestForAsset(from: image) + } + } +} + +/// 排队二维码保存错误。 +enum ScenicQueueQRCodeSaverError: LocalizedError { + case invalidURL + case invalidImage + case denied + + var errorDescription: String? { + switch self { + case .invalidURL: + "服务端返回的二维码地址无效" + case .invalidImage: + "下载或保存二维码失败,请检查网络与存储权限" + case .denied: + "未授予权限,无法将小程序码保存到相册" + } + } +} diff --git a/suixinkan/Features/ScenicQueue/Services/ScenicQueueSocketClient.swift b/suixinkan/Features/ScenicQueue/Services/ScenicQueueSocketClient.swift new file mode 100644 index 0000000..274ecea --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Services/ScenicQueueSocketClient.swift @@ -0,0 +1,73 @@ +// +// ScenicQueueSocketClient.swift +// suixinkan +// + +import Foundation + +/// 排队 WebSocket 客户端,订阅打卡点队列刷新与叫号事件。 +final class ScenicQueueSocketClient { + var onMessage: ((ScenicQueueSocketMessage) -> Void)? + + private let environment: APIEnvironment + private let session: URLSession + private let decoder = JSONDecoder() + private var task: URLSessionWebSocketTask? + private var connectedSpotId: Int64? + + init(environment: APIEnvironment = .current, session: URLSession = .shared) { + self.environment = environment + self.session = session + } + + /// 建立连接并订阅指定打卡点。 + func connect(socketToken: String, scenicId: Int64, scenicSpotId: Int64) { + guard !socketToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + disconnect() + connectedSpotId = scenicSpotId + let webSocketTask = session.webSocketTask(with: environment.webSocketURL) + task = webSocketTask + webSocketTask.resume() + sendSubscribePayloads(scenicSpotId: scenicSpotId) + receiveLoop() + } + + /// 断开连接。 + func disconnect() { + task?.cancel(with: .normalClosure, reason: nil) + task = nil + connectedSpotId = nil + } + + private func sendSubscribePayloads(scenicSpotId: Int64) { + [ + #"{"type":306,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#, + #"{"type":307,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#, + ].forEach { payload in + task?.send(.string(payload)) { _ in } + } + } + + private func receiveLoop() { + task?.receive { [weak self] result in + guard let self else { return } + switch result { + case .success(let message): + if case .string(let text) = message { + self.handle(text) + } + self.receiveLoop() + case .failure: + self.task = nil + } + } + } + + private func handle(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8) else { return } + guard let message = try? decoder.decode(ScenicQueueSocketMessage.self, from: data) else { return } + guard message.isQueueEvent else { return } + onMessage?(message) + } +} diff --git a/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift b/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift new file mode 100644 index 0000000..627f611 --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift @@ -0,0 +1,228 @@ +// +// ScenicQueueTTSManager.swift +// suixinkan +// + +import AVFoundation +import Foundation + +/// 排队语音播报服务协议,隔离具体 TTS SDK。 +protocol ScenicQueueTTSManaging: AnyObject { + /// 自定义文案是否正在循环播报。 + var customTextLooping: Bool { get } + + /// 预热排队 TTS 引擎。 + func prepareQueueTTSEngine(api: ScenicQueueAPI?) async + + /// 开始循环播报指定排号。 + func startQueueCallLoop(queueNo: String) + + /// 停止叫号循环。 + func stopQueueCallLoop() + + /// 单次播报普通文本。 + @discardableResult + func speakPlainTextOnce(_ text: String) async -> Bool + + /// 开始循环播报自定义文本。 + @discardableResult + func startCustomTextLoop(_ text: String) async -> Bool + + /// 停止自定义文本循环。 + func stopCustomTextLoop() + + /// 中断拍摄倒计时等临时播报。 + func interruptShootingAnnouncement() + + /// 估算播报耗时。 + func estimateAnnouncementDurationMillis(_ text: String) -> Int64 +} + +/// 阿里云 NLS TTS 管理器。 +/// +/// 当前工程未随仓库提供阿里云 iOS NativeNui SDK,类中保留 STS/NLS 预热链路与 SDK 桥接边界; +/// 在 SDK framework 加入后可在 `prepareNativeEngineIfAvailable` 内完成真实 NativeNui 初始化。 +final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate { + static let shared = AliyunNLSTTSManager() + + private enum LoopKind { + case queueCall(String) + case custom(String) + } + + private let synthesizer = AVSpeechSynthesizer() + private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts") + private var activeLoop: LoopKind? + private var currentContinuation: CheckedContinuation? + private var nlsToken: String? + private var lastCredentials: AliyunOSSCredentials? + + private(set) var customTextLooping = false + + override init() { + super.init() + synthesizer.delegate = self + } + + func prepareQueueTTSEngine(api: ScenicQueueAPI?) async { + guard let api else { return } + do { + let sts = try await api.aliyunSTS(bucket: "vipsky") + lastCredentials = sts.credentials + nlsToken = nil + prepareNativeEngineIfAvailable(credentials: sts.credentials) + } catch { + // Voice playback falls back to AVSpeechSynthesizer if NLS warm-up fails. + } + } + + func startQueueCallLoop(queueNo: String) { + let text = Self.callAnnouncementText(queueNo: queueNo) + loopQueue.async { [weak self] in + guard let self else { return } + self.activeLoop = .queueCall(queueNo) + self.customTextLooping = false + Task { await self.runLoop(text: text, expected: .queueCall(queueNo)) } + } + } + + func stopQueueCallLoop() { + loopQueue.async { [weak self] in + guard let self else { return } + if case .queueCall = self.activeLoop { + self.activeLoop = nil + } + self.stopSpeaking() + } + } + + @discardableResult + func speakPlainTextOnce(_ text: String) async -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + await speak(trimmed) + return true + } + + @discardableResult + func startCustomTextLoop(_ text: String) async -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + loopQueue.async { [weak self] in + guard let self else { return } + self.activeLoop = .custom(trimmed) + self.customTextLooping = true + Task { await self.runLoop(text: trimmed, expected: .custom(trimmed)) } + } + return true + } + + func stopCustomTextLoop() { + loopQueue.async { [weak self] in + guard let self else { return } + if case .custom = self.activeLoop { + self.activeLoop = nil + } + self.customTextLooping = false + self.stopSpeaking() + } + } + + func interruptShootingAnnouncement() { + stopSpeaking() + } + + func estimateAnnouncementDurationMillis(_ text: String) -> Int64 { + let count = max(1, text.count) + return Int64(min(max(850 + count * 200, 900), 12_000)) + } + + /// 排队号 TTS 格式化,降低字母数字连读概率。 + static func formatQueueNoForTTS(_ queueNo: String) -> String { + let trimmed = queueNo.trimmingCharacters(in: .whitespacesAndNewlines) + let core = trimmed.hasSuffix("号") ? String(trimmed.dropLast()) : trimmed + guard !core.isEmpty else { return trimmed.isEmpty ? "请" : trimmed } + let pattern = #"^([A-Za-z]+)([0-9]+)$"# + if let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: core, range: NSRange(core.startIndex..., in: core)), + let lettersRange = Range(match.range(at: 1), in: core), + let digitsRange = Range(match.range(at: 2), in: core) { + return "\(core[lettersRange].uppercased()),\(core[digitsRange])号" + } + return "\(core)号" + } + + /// 叫号播报文案。 + static func callAnnouncementText(queueNo: String) -> String { + "\(formatQueueNoForTTS(queueNo)),请到拍摄区域,准备拍摄。" + } + + private func runLoop(text: String, expected: LoopKind) async { + while isLoopStillActive(expected) { + await speak(text) + guard isLoopStillActive(expected) else { break } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + if case .custom = expected { + customTextLooping = false + } + } + + private func isLoopStillActive(_ expected: LoopKind) -> Bool { + loopQueue.sync { + switch (activeLoop, expected) { + case (.queueCall(let lhs), .queueCall(let rhs)): + lhs == rhs + case (.custom(let lhs), .custom(let rhs)): + lhs == rhs + default: + false + } + } + } + + private func speak(_ text: String) async { + await MainActor.run { + if synthesizer.isSpeaking { + synthesizer.stopSpeaking(at: .immediate) + } + } + await withCheckedContinuation { continuation in + currentContinuation = continuation + let utterance = AVSpeechUtterance(string: text) + utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") + utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.86 + utterance.volume = 1.0 + DispatchQueue.main.async { + self.synthesizer.speak(utterance) + } + } + } + + private func stopSpeaking() { + DispatchQueue.main.async { + if self.synthesizer.isSpeaking { + self.synthesizer.stopSpeaking(at: .immediate) + } + self.resumeCurrentContinuationIfNeeded() + } + } + + private func resumeCurrentContinuationIfNeeded() { + currentContinuation?.resume() + currentContinuation = nil + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + resumeCurrentContinuationIfNeeded() + } + + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + resumeCurrentContinuationIfNeeded() + } + + private func prepareNativeEngineIfAvailable(credentials: AliyunOSSCredentials) { + _ = NSClassFromString("NativeNui") + _ = credentials + } +} diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift new file mode 100644 index 0000000..6ab9e05 --- /dev/null +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift @@ -0,0 +1,81 @@ +// +// ScenicQueueSettingChangeLogViewModel.swift +// suixinkan +// + +import Foundation + +/// 排队设置配置日志 ViewModel。 +final class ScenicQueueSettingChangeLogViewModel { + private enum Constants { + static let pageSize = 20 + } + + private(set) var items: [ScenicQueueSettingChangeLogItem] = [] + private(set) var isRefreshing = false + private(set) var isLoadingMore = false + private(set) var canLoadMore = false + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + + private let appStore: AppStore + private var total = 0 + private var lastLoadedPage = 0 + + init(appStore: AppStore = .shared) { + self.appStore = appStore + } + + /// 刷新配置日志。 + func refresh(api: ScenicQueueAPIProtocol) async { + await load(api: api, page: 1, append: false) + } + + /// 加载更多配置日志。 + func loadMore(api: ScenicQueueAPIProtocol) async { + guard canLoadMore, !isRefreshing, !isLoadingMore else { return } + await load(api: api, page: lastLoadedPage + 1, append: true) + } + + private func load(api: ScenicQueueAPIProtocol, page: Int, append: Bool) async { + guard appStore.currentScenicId > 0 else { + onShowMessage?("请先选择景区") + return + } + if append { + isLoadingMore = true + } else { + isRefreshing = true + } + notifyStateChange() + defer { + if append { isLoadingMore = false } else { isRefreshing = false } + notifyStateChange() + } + let spotId = Int64(appStore.scenicQueuePunchSpotId) + do { + let data = try await api.settingChangeLog( + scenicId: Int64(appStore.currentScenicId), + scenicSpotId: spotId, + page: page, + pageSize: Constants.pageSize + ) + if append { + let seen = Set(items.map(\.id)) + items += data.list.filter { !seen.contains($0.id) } + } else { + items = data.list + } + total = max(0, data.total) + lastLoadedPage = max(1, data.page) + canLoadMore = items.count < total && !data.list.isEmpty + } catch { + onShowMessage?(error.localizedDescription) + } + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift new file mode 100644 index 0000000..83a7527 --- /dev/null +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift @@ -0,0 +1,496 @@ +// +// ScenicQueueSettingsViewModel.swift +// suixinkan +// + +import Foundation + +/// 排队设置页 ViewModel,对齐 Android `ScenicQueueSettingsViewModel`。 +final class ScenicQueueSettingsViewModel { + private enum Limits { + static let remarkMaxLen = 255 + static let shootMinute = 0...30 + static let shootSecond = 0...59 + static let ahead = 0...50 + static let broadcastInterval = 40...60 + static let readableThreshold = 10...30 + static let queueDistance = 0...9_999_990 + static let queueTakeLimit = 0...999_999 + static let missCallRequeueOffset = 1...9_999 + static let autoCallAheadCount = 0...5 + } + + private(set) var punchSpotOptions: [ScenicQueuePunchSpotOption] = [] + private(set) var selectedPunchSpotId: String? + private(set) var selectedPunchSpotLabel: String + private(set) var shootMinute: Int + private(set) var shootSecond: Int + private(set) var firstAheadCount: Int + private(set) var firstSms: Bool + private(set) var firstPhone: Bool + private(set) var secondAheadCount: Int + private(set) var secondSms: Bool + private(set) var secondPhone: Bool + private(set) var broadcastIntervalSec: Int + private(set) var countdownThresholdSec: Int + private(set) var queueDistanceMeter: Int + private(set) var queueTakeLimit: Int + private(set) var missCallRequeueOffset: Int + private(set) var showStartShootingButton: Bool + private(set) var autoCallAheadCount: Int + private(set) var quickCallButtonEnabled: Bool + private(set) var prepareCallButtonEnabled: Bool + private(set) var businessOpen: Bool + private(set) var businessStartTime: String + private(set) var businessEndTime: String + private(set) var customTTSText: String + private(set) var presetVoices: [String] + private(set) var settingsTab: ScenicQueueSettingsTab = .basic + private(set) var highlightedInvalidFieldKeys: Set = [] + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onNavigateBack: (() -> Void)? + var onNavigateLog: (() -> Void)? + + private let appStore: AppStore + private let ttsManager: ScenicQueueTTSManaging + private var punchSpotSettingRequestGeneration = 0 + private var ttsLoopAnchorText: String? + + init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) { + self.appStore = appStore + self.ttsManager = ttsManager + let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() + selectedPunchSpotId = appStore.scenicQueuePunchSpotId.nonEmptyTrimmed + selectedPunchSpotLabel = appStore.scenicQueuePunchSpotName + shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute) + shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond) + firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead) + firstSms = snapshot.firstSms + firstPhone = snapshot.firstPhone + secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead) + secondSms = snapshot.secondSms + secondPhone = snapshot.secondPhone + broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval) + countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold) + queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance) + queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit) + missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset) + showStartShootingButton = snapshot.showStartShootingButton + autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount) + quickCallButtonEnabled = snapshot.quickCallButtonEnabled + prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled + businessOpen = snapshot.businessOpen + businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00") + businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00") + customTTSText = appStore.scenicQueueRemark + presetVoices = appStore.scenicQueuePresetVoices() + } + + var selectedPunchSpotDisplayText: String { + if let selectedPunchSpotId, + let option = punchSpotOptions.first(where: { $0.id == selectedPunchSpotId }), + !option.label.isEmpty { + return option.label + } + if !selectedPunchSpotLabel.isEmpty { + return selectedPunchSpotLabel + } + if let selectedPunchSpotId, !selectedPunchSpotId.isEmpty { + return "打卡点(ID \(selectedPunchSpotId))" + } + return "请选择打卡点" + } + + func openSettingChangeLog() { + onNavigateLog?() + } + + /// 打开打卡点选择器前加载全部打卡点。 + func loadPunchSpotOptions(api: ScenicQueueAPIProtocol) async { + guard appStore.currentScenicId > 0 else { + onShowMessage?("请先选择景区") + return + } + do { + let response = try await api.scenicSpotListAll(scenicId: String(appStore.currentScenicId)) + punchSpotOptions = response.list.map { + ScenicQueuePunchSpotOption(id: String($0.id), label: $0.name) + } + if punchSpotOptions.isEmpty { + onShowMessage?("暂无可选打卡点") + } + notifyStateChange() + } catch { + onShowMessage?(error.localizedDescription) + } + } + + /// 选择打卡点并尝试拉取服务端设置。 + func setPunchSpot(_ option: ScenicQueuePunchSpotOption, api: ScenicQueueAPIProtocol) async { + selectedPunchSpotId = option.id + selectedPunchSpotLabel = option.label.trimmingCharacters(in: .whitespacesAndNewlines) + highlightedInvalidFieldKeys.remove("punchSpot") + notifyStateChange() + await fetchQueueSettingAfterPunchSpotSelected(option.id, api: api) + } + + func setSettingsTab(_ tab: ScenicQueueSettingsTab) { + settingsTab = tab + 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() } + func setSecondAheadCount(_ value: Int) { secondAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() } + func setBroadcastIntervalSec(_ value: Int) { broadcastIntervalSec = value.clamped(to: Limits.broadcastInterval); notifyStateChange() } + func setCountdownThresholdSec(_ value: Int) { countdownThresholdSec = value.clamped(to: Limits.readableThreshold); notifyStateChange() } + func setQueueDistanceMeter(_ value: Int) { queueDistanceMeter = value.clamped(to: Limits.queueDistance); notifyStateChange() } + func setQueueTakeLimit(_ value: Int) { queueTakeLimit = value.clamped(to: Limits.queueTakeLimit); notifyStateChange() } + func setMissCallRequeueOffset(_ value: Int) { missCallRequeueOffset = value.clamped(to: Limits.missCallRequeueOffset); notifyStateChange() } + func setAutoCallAheadCount(_ value: Int) { autoCallAheadCount = value.clamped(to: Limits.autoCallAheadCount); notifyStateChange() } + func toggleShowStartShootingButton() { showStartShootingButton.toggle(); notifyStateChange() } + func toggleQuickCallButtonEnabled() { quickCallButtonEnabled.toggle(); notifyStateChange() } + func togglePrepareCallButtonEnabled() { prepareCallButtonEnabled.toggle(); notifyStateChange() } + func toggleBusinessOpen() { businessOpen.toggle(); notifyStateChange() } + func toggleFirstSms() { firstSms.toggle(); notifyStateChange() } + func toggleFirstPhone() { firstPhone.toggle(); notifyStateChange() } + func toggleSecondSms() { secondSms.toggle(); notifyStateChange() } + func toggleSecondPhone() { secondPhone.toggle(); notifyStateChange() } + + func setBusinessStartTime(_ value: String) { + businessStartTime = Self.normalizedTime(value, fallback: businessStartTime) + notifyStateChange() + } + + func setBusinessEndTime(_ value: String) { + businessEndTime = Self.normalizedTime(value, fallback: businessEndTime) + notifyStateChange() + } + + func setCustomTTSText(_ value: String) { + customTTSText = String(value.prefix(Limits.remarkMaxLen)) + notifyStateChange() + } + + /// 播放或停止自定义 TTS 文案。 + func toggleCustomTTSPlayback() { + if ttsManager.customTextLooping { + ttsManager.stopCustomTextLoop() + ttsLoopAnchorText = nil + notifyStateChange() + return + } + let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + onShowMessage?("请输入要播报的文字") + return + } + Task { + let ok = await ttsManager.startCustomTextLoop(text) + if ok { + ttsLoopAnchorText = text + } else { + ttsLoopAnchorText = nil + onShowMessage?("播报失败,请检查网络与语音服务") + } + notifyStateChange() + } + } + + /// 播放蓝牙测试音。 + func playBluetoothTestSound() { + Task { + let ok = await ttsManager.speakPlainTextOnce("蓝牙音响测试音,请确认外放音量是否合适。") + if !ok { + onShowMessage?("测试音播放失败,请检查网络与语音服务") + } + } + } + + /// 保存当前文案到本地预设。 + func saveCurrentTextAsPresetVoiceLocal() { + guard appStore.currentScenicId > 0, selectedPunchSpotId?.nonEmptyTrimmed != nil else { + onShowMessage?("请先选择打卡点") + return + } + let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + onShowMessage?("请输入要播报的文字") + return + } + if presetVoices.count >= 5 { + onShowMessage?("最多保存5个语音") + return + } + if presetVoices.contains(text) { + onShowMessage?("该文案已在列表中") + return + } + presetVoices.append(text) + appStore.saveScenicQueuePresetVoices(presetVoices) + onShowMessage?("已保存到本地") + notifyStateChange() + } + + func deletePresetVoice(at index: Int) { + guard presetVoices.indices.contains(index) else { return } + let removed = presetVoices.remove(at: index) + appStore.saveScenicQueuePresetVoices(presetVoices) + if ttsLoopAnchorText == removed { + ttsManager.stopCustomTextLoop() + ttsLoopAnchorText = nil + } + notifyStateChange() + } + + /// 保存排队打卡点二维码到相册。 + func saveShootQueueQRCode(api: ScenicQueueAPIProtocol) async { + guard let ids = currentScenicAndSpotIdsForSelected() else { + onShowMessage?("请先选择打卡点") + return + } + do { + let data = try await api.shootQueueQrcode(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId) + guard let url = data.qrcodeUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty else { + onShowMessage?("服务端未返回二维码图片地址") + return + } + try await ScenicQueueQRCodeSaver.saveImage(from: url) + onShowMessage?("保存成功") + } catch { + onShowMessage?((error as? LocalizedError)?.errorDescription ?? error.localizedDescription) + } + } + + /// 校验并保存设置。 + func save(api: ScenicQueueAPIProtocol) async { + highlightedInvalidFieldKeys = [] + guard let ids = currentScenicAndSpotIdsForSelected() else { + highlightedInvalidFieldKeys = ["punchSpot"] + settingsTab = .basic + onShowMessage?("请选择打卡点") + notifyStateChange() + return + } + let validation = buildSaveValidation() + guard validation.messages.isEmpty else { + highlightedInvalidFieldKeys = validation.keys + settingsTab = tabForValidationKeys(validation.keys) + onShowMessage?(validation.messages.joined(separator: "\n")) + notifyStateChange() + return + } + let remark = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines) + let request = ScenicQueueSaveSettingRequest( + scenicId: ids.scenicId, + scenicSpotId: ids.scenicSpotId, + photoEstimateMin: shootMinute, + photoEstimateSec: shootSecond, + firstNoticeThresholdPos: firstAheadCount, + firstNoticeSmsEnabled: firstSms ? 1 : 0, + firstNoticeCallEnabled: firstPhone ? 1 : 0, + secondNoticeThresholdPos: secondAheadCount, + secondNoticeSmsEnabled: secondSms ? 1 : 0, + secondNoticeCallEnabled: secondPhone ? 1 : 0, + countdownBroadcastIntervalSec: broadcastIntervalSec, + countdownReadableThresholdSec: countdownThresholdSec, + queueDistanceMeter: queueDistanceMeter, + queueTakeLimit: queueTakeLimit, + missCallRequeueOffset: missCallRequeueOffset, + showStartShootButton: showStartShootingButton ? 1 : 0, + autoCallNextCount: autoCallAheadCount, + businessStartTime: businessStartTime, + businessEndTime: businessEndTime, + voiceBroadcasts: presetVoices.enumerated().map { ScenicQueueVoiceBroadcastItem(content: $0.element, sortOrder: $0.offset + 1) }, + status: businessOpen ? 1 : 0, + remark: remark.isEmpty ? nil : remark + ) + do { + try await api.saveSetting(request) + appStore.scenicQueuePunchSpotId = String(ids.scenicSpotId) + appStore.scenicQueuePunchSpotName = selectedPunchSpotLabel + appStore.scenicQueueRemark = remark + appStore.saveScenicQueuePresetVoices(presetVoices) + appStore.saveScenicQueueSettingsSnapshot(currentSnapshot()) + _ = try? await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId) + onShowMessage?("保存成功") + onNavigateBack?() + } catch { + onShowMessage?(error.localizedDescription) + } + } + + private func fetchQueueSettingAfterPunchSpotSelected(_ spotId: String, api: ScenicQueueAPIProtocol) async { + guard let scenicId = Int64("\(appStore.currentScenicId)"), + let scenicSpotId = Int64(spotId), + scenicId > 0, + scenicSpotId > 0 else { return } + punchSpotSettingRequestGeneration += 1 + let generation = punchSpotSettingRequestGeneration + do { + let data = try await api.setting(scenicId: scenicId, scenicSpotId: scenicSpotId) + guard generation == punchSpotSettingRequestGeneration, + selectedPunchSpotId == spotId, + data.exists, + let setting = data.setting else { return } + applyServerSettingToForm(setting) + } catch { + // Keep local form values if remote setting cannot be loaded. + } + } + + private func applyServerSettingToForm(_ setting: ScenicQueueSettingItem) { + let fallback = ScenicQueueSettingsSnapshot( + showStartShootingButton: showStartShootingButton, + autoCallAheadCount: autoCallAheadCount, + quickCallButtonEnabled: quickCallButtonEnabled, + prepareCallButtonEnabled: prepareCallButtonEnabled + ) + let snapshot = setting.toSnapshot(fallback: fallback) + apply(snapshot) + customTTSText = String((setting.remark ?? "").prefix(Limits.remarkMaxLen)) + if let voices = setting.voiceBroadcasts { + presetVoices = voices + .sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) } + .compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed } + .prefix(5) + .map { $0 } + } + if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed { + selectedPunchSpotLabel = name + } + highlightedInvalidFieldKeys = [] + notifyStateChange() + } + + private func apply(_ snapshot: ScenicQueueSettingsSnapshot) { + shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute) + shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond) + firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead) + firstSms = snapshot.firstSms + firstPhone = snapshot.firstPhone + secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead) + secondSms = snapshot.secondSms + secondPhone = snapshot.secondPhone + broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval) + countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold) + queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance) + queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit) + missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset) + showStartShootingButton = snapshot.showStartShootingButton + autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount) + quickCallButtonEnabled = snapshot.quickCallButtonEnabled + prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled + businessOpen = snapshot.businessOpen + businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00") + businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00") + } + + private func currentSnapshot() -> ScenicQueueSettingsSnapshot { + ScenicQueueSettingsSnapshot( + shootMinute: shootMinute, + shootSecond: shootSecond, + firstAheadCount: firstAheadCount, + firstSms: firstSms, + firstPhone: firstPhone, + secondAheadCount: secondAheadCount, + secondSms: secondSms, + secondPhone: secondPhone, + broadcastIntervalSec: broadcastIntervalSec, + countdownThresholdSec: countdownThresholdSec, + queueDistanceMeter: queueDistanceMeter, + queueTakeLimit: queueTakeLimit, + missCallRequeueOffset: missCallRequeueOffset, + showStartShootingButton: showStartShootingButton, + autoCallAheadCount: autoCallAheadCount, + quickCallButtonEnabled: quickCallButtonEnabled, + prepareCallButtonEnabled: prepareCallButtonEnabled, + businessOpen: businessOpen, + businessStartTime: businessStartTime, + businessEndTime: businessEndTime + ) + } + + private func buildSaveValidation() -> (messages: [String], keys: Set) { + var messages: [String] = [] + var keys: Set = [] + if !Limits.shootMinute.contains(shootMinute) { + messages.append("拍摄时长:分钟须在 0~30,且按 1 分钟递增") + keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue) + } + if !Limits.shootSecond.contains(shootSecond) { + messages.append("拍摄时长:秒须在 0~59") + keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue) + } + if shootMinute == 0 && shootSecond == 0 { + messages.append("拍摄时长须大于 0") + keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue) + keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue) + } + if !Limits.broadcastInterval.contains(broadcastIntervalSec) { + messages.append("播报间隔时间须在 40~60 秒") + keys.insert(ScenicQueueNumericFieldKey.broadcastIntervalSec.rawValue) + } + if !Limits.readableThreshold.contains(countdownThresholdSec) { + messages.append("进入读秒倒计时阈值须在 10~30 秒") + keys.insert(ScenicQueueNumericFieldKey.countdownReadableThresholdSec.rawValue) + } + return (messages, keys) + } + + private func tabForValidationKeys(_ keys: Set) -> ScenicQueueSettingsTab { + if keys.contains("punchSpot") + || keys.contains(ScenicQueueNumericFieldKey.queueDistanceMeter.rawValue) + || keys.contains(ScenicQueueNumericFieldKey.shootMinute.rawValue) + || keys.contains(ScenicQueueNumericFieldKey.shootSecond.rawValue) + || keys.contains(ScenicQueueNumericFieldKey.queueTakeLimit.rawValue) + || keys.contains(ScenicQueueNumericFieldKey.missCallRequeueOffset.rawValue) { + return .basic + } + if keys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue) + || keys.contains(ScenicQueueNumericFieldKey.secondNoticeAhead.rawValue) { + return .notify + } + return .voice + } + + private func currentScenicAndSpotIdsForSelected() -> (scenicId: Int64, scenicSpotId: Int64)? { + guard let scenicId = Int64("\(appStore.currentScenicId)"), scenicId > 0, + let spotIdText = selectedPunchSpotId?.trimmingCharacters(in: .whitespacesAndNewlines), + let scenicSpotId = Int64(spotIdText), scenicSpotId > 0 else { return nil } + return (scenicId, scenicSpotId) + } + + private static func normalizedTime(_ raw: String, fallback: String) -> String { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + for format in ["HH:mm", "HH:mm:ss"] { + formatter.dateFormat = format + if let date = formatter.date(from: text) { + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + } + return fallback + } + + private func notifyStateChange() { + onStateChange?() + } +} + +private extension Int { + func clamped(to range: ClosedRange) -> Int { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} + +private extension String { + var nonEmptyTrimmed: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift new file mode 100644 index 0000000..a2aa12c --- /dev/null +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift @@ -0,0 +1,682 @@ +// +// ScenicQueueViewModel.swift +// suixinkan +// + +import Foundation + +/// 排队管理首页 ViewModel,对齐 Android `ScenicQueueViewModel`。 +final class ScenicQueueViewModel { + private enum Constants { + static let pageSize = 10 + static let typeCurrent = 1 + static let typeFinished = 2 + static let prepareCallLoopGapNanos: UInt64 = 800_000_000 + } + + private(set) var summary: ScenicQueueSummary = .empty + private(set) var currentQueue: [ScenicQueueTicket] = [] + private(set) var skippedQueue: [ScenicQueueSkippedTicket] = [] + private(set) var currentRefreshing = false + private(set) var skippedRefreshing = false + private(set) var currentLoadingMore = false + private(set) var skippedLoadingMore = false + private(set) var currentCanLoadMore = false + private(set) var skippedCanLoadMore = false + private(set) var queueGatePassed = false + private(set) var selectedPunchSpotLine = "--" + private(set) var shootingTimedQueueNo: String? + private(set) var shootingRemainSeconds = 0 + private(set) var showStartShootingButton = true + private(set) var quickCallButtonEnabled = false + private(set) var prepareCallButtonEnabled = false + private(set) var prepareCallPlaying = false + private(set) var pendingSkip: ScenicQueuePendingSkip? + private(set) var pendingCall: ScenicQueuePendingCall? + private(set) var pendingFinish: ScenicQueuePendingFinish? + private(set) var pendingMark: ScenicQueuePendingMark? + private(set) var pendingRequeue: ScenicQueuePendingRequeue? + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onRequireSettings: (() -> Void)? + var onNavigateSettings: (() -> Void)? + var onNavigateBack: (() -> Void)? + + private let appStore: AppStore + private let ttsManager: ScenicQueueTTSManaging + private let socketClient: ScenicQueueSocketClient + private var currentListTotal = 0 + private var skippedListTotal = 0 + private var currentLastLoadedPage = 0 + private var skippedLastLoadedPage = 0 + private var pendingRemoteCalledRecordIds: Set = [] + private var handledRemoteCallEventIds: Set = [] + private var shootingTask: Task? + private var prepareCallTask: Task? + + init( + appStore: AppStore = .shared, + ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared, + socketClient: ScenicQueueSocketClient = ScenicQueueSocketClient() + ) { + self.appStore = appStore + self.ttsManager = ttsManager + self.socketClient = socketClient + self.socketClient.onMessage = { [weak self] message in + self?.handleSocketMessage(message) + } + refreshSelectedPunchSpotLine() + refreshQueueGateLocally() + } + + deinit { + stopQueueStatsPolling() + shootingTask?.cancel() + prepareCallTask?.cancel() + ttsManager.stopQueueCallLoop() + ttsManager.stopCustomTextLoop() + } + + /// 刷新排队 gate,配置过打卡点才加载数据。 + func refreshQueueGate(api: ScenicQueueAPIProtocol) async { + refreshSelectedPunchSpotLine() + refreshQueueGateLocally() + if queueGatePassed { + await syncQueueSettingFromServerIfMatchesLocal(api: api) + await refreshQueueStats(api: api) + await refreshCurrentQueue(api: api, reset: true) + await refreshSkippedQueue(api: api, reset: true) + await ttsManager.prepareQueueTTSEngine(api: api as? ScenicQueueAPI) + } else { + resetQueues() + onRequireSettings?() + } + } + + /// 开始 WebSocket 订阅。 + func startQueueStatsPolling(api: ScenicQueueAPIProtocol) { + guard queueGatePassed, + let scenicId = Int64("\(appStore.currentScenicId)"), + let scenicSpotId = Int64(appStore.scenicQueuePunchSpotId), + scenicId > 0, + scenicSpotId > 0 else { return } + Task { + do { + let token = try await api.socketToken().socketToken + socketClient.connect(socketToken: token, scenicId: scenicId, scenicSpotId: scenicSpotId) + } catch { + onShowMessage?(error.localizedDescription) + } + } + } + + /// 停止 WebSocket 订阅。 + func stopQueueStatsPolling() { + socketClient.disconnect() + } + + /// 打开设置页。 + func openSettings() { + if shootingTimedQueueNo != nil { + onShowMessage?("请先完成上一位拍摄,才可以进入设置页面。") + return + } + onNavigateSettings?() + } + + /// 退出排队模块。 + func exitQueueModule() { + onNavigateBack?() + } + + /// 刷新当前排队列表。 + func refreshCurrentQueue(api: ScenicQueueAPIProtocol, reset: Bool = true) async { + await loadHomeList(api: api, type: Constants.typeCurrent, page: 1, append: false, isRefresh: reset) + } + + /// 加载更多当前排队。 + func loadMoreCurrentQueue(api: ScenicQueueAPIProtocol) async { + guard currentCanLoadMore, !currentRefreshing, !currentLoadingMore else { return } + await loadHomeList(api: api, type: Constants.typeCurrent, page: currentLastLoadedPage + 1, append: true, isRefresh: false) + } + + /// 刷新过号列表。 + func refreshSkippedQueue(api: ScenicQueueAPIProtocol, reset: Bool = true) async { + await loadHomeList(api: api, type: Constants.typeFinished, page: 1, append: false, isRefresh: reset) + } + + /// 加载更多过号列表。 + func loadMoreSkippedQueue(api: ScenicQueueAPIProtocol) async { + guard skippedCanLoadMore, !skippedRefreshing, !skippedLoadingMore else { return } + await loadHomeList(api: api, type: Constants.typeFinished, page: skippedLastLoadedPage + 1, append: true, isRefresh: false) + } + + /// 刷新统计。 + func refreshQueueStats(api: ScenicQueueAPIProtocol) async { + guard let ids = currentScenicAndSpotIds() else { return } + do { + let data = try await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId) + let avg = data.avgWaitMin.isFinite ? max(0, Int(data.avgWaitMin.rounded())) : 0 + summary = ScenicQueueSummary(peopleInQueue: max(0, data.queueCount), avgWaitMinutes: avg) + notifyStateChange() + } catch { + // Android ignores stats errors silently. + } + } + + /// 请求标记用户。 + func onTicketMarkClick(recordId: Int64, queueNo: String, uid: Int64) { + guard recordId > 0, uid > 0, !queueNo.isEmpty, + let ticket = currentQueue.first(where: { $0.recordId == recordId }) else { return } + pendingMark = ScenicQueuePendingMark( + uid: uid, + queueNo: queueNo, + phoneMasked: ticket.phoneMasked, + markAsFreelancePhotog: ticket.markAsFreelancePhotog + ) + notifyStateChange() + } + + func dismissMarkDialog() { + pendingMark = nil + notifyStateChange() + } + + /// 确认用户标记。 + func confirmUserMark(api: ScenicQueueAPIProtocol, markAsFreelancePhotog: Bool, queueBanDays: Int) async { + guard let pendingMark else { return } + guard appStore.currentScenicId > 0 else { + onShowMessage?("请先选择景区") + return + } + let request = ScenicQueueUserMarkRequest( + uid: pendingMark.uid, + scenicId: Int64(appStore.currentScenicId), + markAsFreelancePhotog: markAsFreelancePhotog ? 1 : 0, + queueBanDays: markAsFreelancePhotog ? min(max(queueBanDays, 0), 999) : nil, + operatorId: Int(appStore.userId) + ) + do { + try await api.userMark(request) + dismissMarkDialog() + await refreshCurrentQueue(api: api, reset: true) + } catch { + onShowMessage?(error.localizedDescription) + } + } + + func requestSkipTicket(recordId: Int64, queueNo: String) { + guard recordId > 0, !queueNo.isEmpty else { return } + pendingSkip = ScenicQueuePendingSkip(recordId: recordId, queueNo: queueNo) + notifyStateChange() + } + + func dismissSkipDialog() { + pendingSkip = nil + notifyStateChange() + } + + /// 确认过号。 + func confirmSkipTicket(api: ScenicQueueAPIProtocol) async { + guard let pending = pendingSkip else { return } + dismissSkipDialog() + ttsManager.stopQueueCallLoop() + do { + _ = try await api.pass(recordId: pending.recordId) + clearShootingSession(for: pending.queueNo) + removeTicketFromCurrentList(recordId: pending.recordId) + await refreshQueueStats(api: api) + await refreshSkippedQueue(api: api, reset: true) + } catch { + onShowMessage?(error.localizedDescription) + } + } + + func requestFinishConfirm(recordId: Int64, queueNo: String) { + guard recordId > 0, !queueNo.isEmpty else { return } + if let active = shootingTimedQueueNo, active != queueNo { + onShowMessage?("请先完成上一位拍摄。") + return + } + pendingFinish = ScenicQueuePendingFinish(recordId: recordId, queueNo: queueNo) + notifyStateChange() + } + + func dismissFinishDialog() { + pendingFinish = nil + notifyStateChange() + } + + /// 确认完成。 + func confirmFinishTicket(api: ScenicQueueAPIProtocol) async { + guard let pending = pendingFinish else { return } + dismissFinishDialog() + await finishTicket(api: api, pending: pending) + } + + func requestRequeueConfirm(recordId: Int64, queueNo: String) { + guard recordId > 0, !queueNo.isEmpty else { return } + pendingRequeue = ScenicQueuePendingRequeue(recordId: recordId, queueNo: queueNo) + notifyStateChange() + } + + func dismissRequeueDialog() { + pendingRequeue = nil + notifyStateChange() + } + + /// 确认重新排队。 + func confirmRequeueTicket(api: ScenicQueueAPIProtocol) async { + guard let pending = pendingRequeue else { return } + dismissRequeueDialog() + guard let operatorId = Int(appStore.userId), operatorId > 0 else { + onShowMessage?("请先登录") + return + } + do { + try await api.requeueInsertBefore(recordId: pending.recordId, operatorId: operatorId) + await refreshQueueStats(api: api) + await refreshCurrentQueue(api: api, reset: true) + await refreshSkippedQueue(api: api, reset: true) + } catch { + onShowMessage?(error.localizedDescription) + } + } + + func requestCallConfirm(recordId: Int64, queueNo: String, isRecall: Bool = false) { + if shootingTimedQueueNo != nil { + onShowMessage?("请先完成上一位拍摄,再进行叫号。") + return + } + guard recordId > 0, !queueNo.isEmpty else { return } + pendingCall = ScenicQueuePendingCall(recordId: recordId, queueNo: queueNo, isRecall: isRecall) + notifyStateChange() + } + + func dismissCallConfirmDialog() { + pendingCall = nil + notifyStateChange() + } + + /// 确认叫号。 + func confirmCallTicket(api: ScenicQueueAPIProtocol) async { + guard let pending = pendingCall else { return } + dismissCallConfirmDialog() + stopPrepareCallPlayback() + await callTicketDirectly(api: api, recordId: pending.recordId, queueNo: pending.queueNo) + } + + /// 快速叫号第一位。 + func quickCallFirstTicket(api: ScenicQueueAPIProtocol) async { + if shootingTimedQueueNo != nil { + onShowMessage?("请先完成上一位拍摄,再进行叫号。") + return + } + guard let first = currentQueue.first else { + onShowMessage?("暂无可叫号的排队用户") + return + } + stopPrepareCallPlayback() + await callTicketDirectly(api: api, recordId: first.recordId, queueNo: first.queueNo) + } + + /// 切换准备叫号播报。 + func togglePrepareCallPlayback() { + if prepareCallPlaying { + stopPrepareCallPlayback() + return + } + let queueNos = prepareCallQueueNos() + guard !queueNos.isEmpty else { + onShowMessage?("暂无可播报的排队用户") + return + } + prepareCallTask?.cancel() + prepareCallPlaying = true + notifyStateChange() + prepareCallTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + let latest = self.prepareCallQueueNos() + if latest.isEmpty { break } + _ = await self.ttsManager.speakPlainTextOnce(self.prepareCallAnnouncementText(latest)) + try? await Task.sleep(nanoseconds: Constants.prepareCallLoopGapNanos) + } + self.prepareCallPlaying = false + self.prepareCallTask = nil + self.notifyStateChange() + } + } + + /// 开始拍摄倒计时。 + func startShooting(api: ScenicQueueAPIProtocol, recordId: Int64, queueNo: String) { + guard recordId > 0, !queueNo.isEmpty, shootingTimedQueueNo == nil else { return } + stopPrepareCallPlayback() + ttsManager.stopQueueCallLoop() + let timing = resolveShootingTiming() + guard timing.totalSeconds > 0 else { + onShowMessage?("请先在排队配置中设置拍摄时长") + return + } + onShowMessage?("开始计时拍摄") + shootingTimedQueueNo = queueNo + shootingRemainSeconds = timing.totalSeconds + notifyStateChange() + shootingTask?.cancel() + shootingTask = Task { [weak self] in + guard let self else { return } + await self.speakFollowingTickets(after: queueNo) + let start = Date() + while !Task.isCancelled { + let elapsed = Int(Date().timeIntervalSince(start).rounded(.down)) + let remain = max(0, timing.totalSeconds - elapsed) + self.shootingRemainSeconds = remain + self.notifyStateChange() + if remain <= 0 { break } + if remain == timing.readableThresholdSeconds { + _ = await self.ttsManager.speakPlainTextOnce("当前剩余拍摄时间还剩\(remain)秒") + } else if remain < timing.readableThresholdSeconds && remain > 0 { + _ = await self.ttsManager.speakPlainTextOnce("\(remain)") + } else if timing.intervalSeconds > 0 && elapsed > 0 && elapsed % timing.intervalSeconds == 0 { + _ = await self.ttsManager.speakPlainTextOnce(self.shootingRemainAnnouncementText(queueNo: queueNo, remainSeconds: remain)) + } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + self.ttsManager.interruptShootingAnnouncement() + self.shootingTimedQueueNo = nil + self.shootingRemainSeconds = 0 + self.shootingTask = nil + self.notifyStateChange() + if !Task.isCancelled { + await self.finishTicket(api: api, pending: ScenicQueuePendingFinish(recordId: recordId, queueNo: queueNo)) + } + } + } + + private func refreshSelectedPunchSpotLine() { + selectedPunchSpotLine = appStore.hasScenicQueuePunchSpotSaved() + ? appStore.scenicQueuePunchSpotName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "--" + : "--" + refreshShootingCallConfig() + } + + private func refreshQueueGateLocally() { + queueGatePassed = appStore.hasScenicQueuePunchSpotSaved() + } + + private func refreshShootingCallConfig() { + let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() + showStartShootingButton = snapshot.showStartShootingButton + quickCallButtonEnabled = snapshot.quickCallButtonEnabled + prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled + } + + private func syncQueueSettingFromServerIfMatchesLocal(api: ScenicQueueAPIProtocol) async { + guard let ids = currentScenicAndSpotIds() else { return } + do { + let data = try await api.setting(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId) + guard data.exists, let setting = data.setting, + setting.scenicId == nil || setting.scenicId == ids.scenicId, + setting.scenicSpotId == nil || setting.scenicSpotId == ids.scenicSpotId else { return } + let local = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() + appStore.saveScenicQueueSettingsSnapshot(setting.toSnapshot(fallback: local)) + appStore.scenicQueueRemark = setting.remark ?? "" + if let voices = setting.voiceBroadcasts { + appStore.saveScenicQueuePresetVoices( + voices + .sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) } + .compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty } + ) + } + if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty { + appStore.scenicQueuePunchSpotName = name + selectedPunchSpotLine = name + } + refreshShootingCallConfig() + notifyStateChange() + } catch { + // Android keeps local snapshot if sync fails. + } + } + + private func loadHomeList(api: ScenicQueueAPIProtocol, type: Int, page: Int, append: Bool, isRefresh: Bool) async { + guard let ids = currentScenicAndSpotIds() else { return } + let isCurrent = type == Constants.typeCurrent + if isRefresh { + if isCurrent { + guard !currentRefreshing else { return } + currentRefreshing = true + } else { + guard !skippedRefreshing else { return } + skippedRefreshing = true + } + } else { + if isCurrent { + guard !currentLoadingMore else { return } + currentLoadingMore = true + } else { + guard !skippedLoadingMore else { return } + skippedLoadingMore = true + } + } + notifyStateChange() + defer { + if isRefresh { + if isCurrent { currentRefreshing = false } else { skippedRefreshing = false } + } else { + if isCurrent { currentLoadingMore = false } else { skippedLoadingMore = false } + } + notifyStateChange() + } + + do { + let data = try await api.home( + scenicId: ids.scenicId, + scenicSpotId: ids.scenicSpotId, + type: type, + page: page, + pageSize: Constants.pageSize + ) + guard let block = data.list else { return } + if isCurrent { + let mapped = block.list.map { $0.toCurrentTicket() } + currentQueue = append ? appendUnique(existing: currentQueue, incoming: mapped, key: \.recordId) : mapped + currentListTotal = max(0, block.total) + currentLastLoadedPage = max(1, block.page) + currentCanLoadMore = currentQueue.count < currentListTotal && !block.list.isEmpty + consumePendingRemoteCalledTickets() + } else { + let mapped = block.list.map { $0.toSkippedTicket() } + skippedQueue = append ? appendUnique(existing: skippedQueue, incoming: mapped, key: \.recordId) : mapped + skippedListTotal = max(0, block.total) + skippedLastLoadedPage = max(1, block.page) + skippedCanLoadMore = skippedQueue.count < skippedListTotal && !block.list.isEmpty + } + } catch { + onShowMessage?(error.localizedDescription) + } + } + + private func handleSocketMessage(_ message: ScenicQueueSocketMessage) { + guard let params = message.data?.params, + let changedSpotId = params.scenicSpotId, + let currentSpotId = Int64(appStore.scenicQueuePunchSpotId), + changedSpotId == currentSpotId else { return } + switch message.data?.action { + case ScenicQueueSocketAction.queueUpdated.rawValue: + NotificationCenter.default.post(name: NotificationName.scenicQueueDidUpdate, object: nil) + case ScenicQueueSocketAction.ticketCalled.rawValue: + handleRemoteTicketCalled(params) + NotificationCenter.default.post(name: NotificationName.scenicQueueDidUpdate, object: nil) + default: + break + } + } + + private func handleRemoteTicketCalled(_ params: ScenicQueueSocketParams) { + guard let recordId = params.recordId, recordId > 0 else { return } + if let myUid = Int64(appStore.userId), let operatorUid = params.operatorUid, myUid == operatorUid { + return + } + let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !eventId.isEmpty, handledRemoteCallEventIds.contains(eventId) { + return + } + if !eventId.isEmpty { + handledRemoteCallEventIds.insert(eventId) + } + pendingRemoteCalledRecordIds.insert(recordId) + } + + private func consumePendingRemoteCalledTickets() { + guard let called = currentQueue.first(where: { pendingRemoteCalledRecordIds.contains($0.recordId) && $0.status == .called }) else { return } + pendingRemoteCalledRecordIds.remove(called.recordId) + stopPrepareCallPlayback() + ttsManager.startQueueCallLoop(queueNo: called.queueNo) + } + + private func callTicketDirectly(api: ScenicQueueAPIProtocol, recordId: Int64, queueNo: String) async { + do { + let data = try await api.call(recordId: recordId) + applyCallSuccessLocally(recordId: recordId, queueNo: queueNo, statusText: data.statusText) + } catch { + onShowMessage?(error.localizedDescription) + } + } + + private func applyCallSuccessLocally(recordId: Int64, queueNo: String, statusText: String) { + let shouldShowStartButton = currentShowStartShootingButton() + if shouldShowStartButton { + ttsManager.startQueueCallLoop(queueNo: queueNo) + } + currentQueue = currentQueue.map { ticket in + ticket.recordId == recordId || ticket.queueNo == queueNo + ? ticket.markedCalled(statusText: statusText) + : ticket + } + notifyStateChange() + } + + private func finishTicket(api: ScenicQueueAPIProtocol, pending: ScenicQueuePendingFinish) async { + do { + let data = try await api.finish(recordId: pending.recordId) + ttsManager.stopQueueCallLoop() + clearShootingSession(for: pending.queueNo) + removeTicketFromCurrentList(recordId: pending.recordId) + if !data.statusText.isEmpty { + onShowMessage?(data.statusText) + } + await refreshQueueStats(api: api) + await refreshSkippedQueue(api: api, reset: true) + } catch { + onShowMessage?(error.localizedDescription) + } + } + + private func removeTicketFromCurrentList(recordId: Int64) { + currentQueue.removeAll { $0.recordId == recordId } + currentListTotal = max(0, currentListTotal - 1) + currentCanLoadMore = currentListTotal > 0 && currentQueue.count < currentListTotal + notifyStateChange() + } + + private func clearShootingSession(for queueNo: String) { + guard shootingTimedQueueNo == queueNo else { return } + shootingTask?.cancel() + shootingTask = nil + ttsManager.interruptShootingAnnouncement() + shootingTimedQueueNo = nil + shootingRemainSeconds = 0 + notifyStateChange() + } + + private func stopPrepareCallPlayback() { + prepareCallTask?.cancel() + prepareCallTask = nil + ttsManager.interruptShootingAnnouncement() + prepareCallPlaying = false + notifyStateChange() + } + + private func prepareCallQueueNos() -> [String] { + let active = shootingTimedQueueNo?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return currentQueue + .map { $0.queueNo.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && (active.isEmpty || $0 != active) } + .prefix(3) + .map { $0 } + } + + private func prepareCallAnnouncementText(_ queueNos: [String]) -> String { + let joined = queueNos.map(AliyunNLSTTSManager.formatQueueNoForTTS).joined(separator: "、") + return "\(joined),请提前做好拍摄准备。" + } + + private func resolveShootingTiming() -> (totalSeconds: Int, intervalSeconds: Int, readableThresholdSeconds: Int) { + let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() + let total = max(0, snapshot.shootMinute * 60 + snapshot.shootSecond) + let interval = (40...60).contains(snapshot.broadcastIntervalSec) ? snapshot.broadcastIntervalSec : 50 + let readable = (10...30).contains(snapshot.countdownThresholdSec) ? snapshot.countdownThresholdSec : 15 + return (total, interval, readable) + } + + private func currentShowStartShootingButton() -> Bool { + refreshShootingCallConfig() + return showStartShootingButton + } + + private func speakFollowingTickets(after queueNo: String) async { + let count = (appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot()).autoCallAheadCount.clamped(to: 0...5) + guard count > 0, let index = currentQueue.firstIndex(where: { $0.queueNo == queueNo }) else { return } + let nextNos = currentQueue.dropFirst(index + 1).prefix(count).map(\.queueNo).filter { !$0.isEmpty } + guard !nextNos.isEmpty else { return } + _ = await ttsManager.speakPlainTextOnce("请\(nextNos.map(AliyunNLSTTSManager.formatQueueNoForTTS).joined(separator: "、"))提前做好拍摄准备。") + } + + private func shootingRemainAnnouncementText(queueNo: String, remainSeconds: Int) -> String { + "\(AliyunNLSTTSManager.formatQueueNoForTTS(queueNo)),当前剩余拍摄时间还剩\(remainSeconds)秒" + } + + private func currentScenicAndSpotIds() -> (scenicId: Int64, scenicSpotId: Int64)? { + let scenicId = Int64(appStore.currentScenicId) + guard scenicId > 0, + let scenicSpotId = Int64(appStore.scenicQueuePunchSpotId), + scenicSpotId > 0 else { return nil } + return (scenicId, scenicSpotId) + } + + private func resetQueues() { + summary = .empty + currentQueue = [] + skippedQueue = [] + currentListTotal = 0 + skippedListTotal = 0 + currentLastLoadedPage = 0 + skippedLastLoadedPage = 0 + currentCanLoadMore = false + skippedCanLoadMore = false + notifyStateChange() + } + + private func appendUnique(existing: [Item], incoming: [Item], key: KeyPath) -> [Item] { + var seen = Set(existing.map { $0[keyPath: key] }) + return existing + incoming.filter { seen.insert($0[keyPath: key]).inserted } + } + + private func notifyStateChange() { + onStateChange?() + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} + +private extension Int { + func clamped(to range: ClosedRange) -> Int { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index 76a67b0..543cae6 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -11,7 +11,7 @@ NSPhotoLibraryUsageDescription 需要访问相册以选择头像、身份证和银行卡照片 NSPhotoLibraryAddUsageDescription - 需要保存收款二维码到相册 + 需要保存收款二维码和排队打卡点小程序码到相册 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/suixinkan/Theme/Modules/ScenicQueueTokens.swift b/suixinkan/Theme/Modules/ScenicQueueTokens.swift new file mode 100644 index 0000000..89f6067 --- /dev/null +++ b/suixinkan/Theme/Modules/ScenicQueueTokens.swift @@ -0,0 +1,69 @@ +// +// ScenicQueueTokens.swift +// suixinkan +// + +import UIKit + +/// 排队管理模块视觉令牌,对齐 Android `ScenicQueueTokens`。 +enum ScenicQueueTokens { + static let pageBackground = AppColor.pageBackground + static let bannerBlue = AppColor.primaryBanner + static let bannerDivider = UIColor.white.withAlphaComponent(0.26) + static let bannerMutedLabel = UIColor(hex: 0xC7E0FF) + static let cardOutline = AppColor.cardOutline + static let textSecondary = UIColor(hex: 0x6B7280) + + static let tagWaitingForeground = UIColor(hex: 0x10B981) + static let tagWaitingBackground = UIColor(hex: 0xD1FAE5) + static let tagCalledForeground = AppColor.primary + static let tagCalledBackground = AppColor.infoBackground + static let tagSkippedForeground = UIColor(hex: 0xC62828) + static let tagSkippedBackground = UIColor(hex: 0xFFCDD2) + static let tagMissRequeueForeground = UIColor(hex: 0xFA8C16) + static let tagMissRequeueBackground = UIColor(hex: 0xFFF7E6) + static let tagBanForeground = UIColor(hex: 0xFA8C16) + static let tagBanBackground = UIColor(hex: 0xFFF7E6) + + static let buttonSkipBackground = UIColor(hex: 0xFFF1F2) + static let buttonSkipForeground = UIColor(hex: 0xE11D48) + static let buttonCompleteBackground = AppColor.infoBackground + static let dialogDangerLine = UIColor(hex: 0xD32F2F) + static let fieldBorderMuted = UIColor(hex: 0xE5E7EB) + static let settingsMutedBlock = UIColor(hex: 0xF7F8FA) + + static let radiusContentSheetTop: CGFloat = 14 + static let radiusCard: CGFloat = 12 + static let radiusChip: CGFloat = 4 + static let radiusPrimaryRowButton: CGFloat = 10 + static let radiusThinButton: CGFloat = 8 + static let radiusDialog: CGFloat = 16 + static let radiusInput: CGFloat = 10 + static let radiusTabContainer: CGFloat = 12 + static let listItemGap: CGFloat = 10 + static let listHorizontalInset: CGFloat = 12 + static let bottomSaveButtonRadius: CGFloat = 12 + + static let bannerLabelFont = UIFont.systemFont(ofSize: 12) + static let bannerValueFont = UIFont.systemFont(ofSize: 30, weight: .bold) + static let bannerUnitFont = UIFont.systemFont(ofSize: 12) + static let tabFont = UIFont.systemFont(ofSize: 14) + static let tabSelectedFont = UIFont.systemFont(ofSize: 14, weight: .semibold) + static let ticketIdFont = UIFont.systemFont(ofSize: 20, weight: .bold) + static let statusChipFont = UIFont.systemFont(ofSize: 11, weight: .medium) + static let rowLabelFont = UIFont.systemFont(ofSize: 13) + static let phoneMaskedFont = UIFont.systemFont(ofSize: 15, weight: .medium) + static let metaLabelFont = UIFont.systemFont(ofSize: 12) + static let metaAccentFont = UIFont.systemFont(ofSize: 13, weight: .medium) + static let buttonPrimaryFont = UIFont.systemFont(ofSize: 15, weight: .medium) + static let buttonSecondaryFont = UIFont.systemFont(ofSize: 14, weight: .medium) + static let emptyStateFont = UIFont.systemFont(ofSize: 14) + static let dialogTitleFont = UIFont.systemFont(ofSize: 17, weight: .semibold) + static let dialogTicketFont = UIFont.systemFont(ofSize: 28, weight: .bold) + static let dialogButtonFont = UIFont.systemFont(ofSize: 15, weight: .medium) + static let settingsSectionTitleFont = UIFont.systemFont(ofSize: 16, weight: .semibold) + static let settingsFieldLabelFont = UIFont.systemFont(ofSize: 13) + static let settingsHintFont = UIFont.systemFont(ofSize: 12) + static let settingsRowTitleFont = UIFont.systemFont(ofSize: 14) + static let settingsSaveButtonFont = UIFont.systemFont(ofSize: 16, weight: .medium) +} diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift new file mode 100644 index 0000000..3c1feb4 --- /dev/null +++ b/suixinkan/UI/ScenicQueue/ScenicQueueSettingChangeLogViewController.swift @@ -0,0 +1,152 @@ +// +// ScenicQueueSettingChangeLogViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 排队设置配置日志页。 +final class ScenicQueueSettingChangeLogViewController: BaseViewController { + private let viewModel = ScenicQueueSettingChangeLogViewModel() + private let api: ScenicQueueAPIProtocol + private let tableView = UITableView(frame: .zero, style: .plain) + private let emptyLabel = UILabel() + + @MainActor + init(api: ScenicQueueAPIProtocol? = nil) { + self.api = api ?? NetworkServices.shared.scenicQueueAPI + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + 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 setupUI() { + view.backgroundColor = ScenicQueueTokens.pageBackground + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 120 + tableView.delegate = self + tableView.dataSource = self + tableView.register(ScenicQueueLogCell.self, forCellReuseIdentifier: ScenicQueueLogCell.reuseIdentifier) + tableView.refreshControl = UIRefreshControl() + tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) + emptyLabel.text = "暂无配置变更记录" + emptyLabel.textColor = AppColor.textTertiary + emptyLabel.font = ScenicQueueTokens.emptyStateFont + emptyLabel.textAlignment = .center + view.addSubview(tableView) + view.addSubview(emptyLabel) + } + + override func setupConstraints() { + tableView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + emptyLabel.snp.makeConstraints { make in + make.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) } + } + } + + private func applyState() { + tableView.refreshControl?.endRefreshing() + tableView.reloadData() + emptyLabel.isHidden = !viewModel.items.isEmpty + } + + @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 + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return } + Task { await viewModel.loadMore(api: api) } + } +} + +/// 配置日志 cell。 +final class ScenicQueueLogCell: UITableViewCell { + static let reuseIdentifier = "ScenicQueueLogCell" + + private let card = UIView() + private let titleLabel = UILabel() + private let detailLabel = UILabel() + private let timeLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + 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) + } + } +} diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift new file mode 100644 index 0000000..a3f4ce4 --- /dev/null +++ b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift @@ -0,0 +1,427 @@ +// +// ScenicQueueSettingsViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 排队设置页,对齐 Android `ScenicQueueSettingsScreen` 的三 Tab 表单。 +final class ScenicQueueSettingsViewController: BaseViewController { + 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 saveBar = UIView() + private let saveButton = UIButton(type: .system) + + private var fieldRows: [String: ScenicQueueSettingFieldRow] = [:] + private var switchRows: [String: ScenicQueueSwitchRow] = [:] + + @MainActor + init(api: ScenicQueueAPIProtocol? = nil) { + self.api = api ?? NetworkServices.shared.scenicQueueAPI + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + 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) + ) + } + + 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) + + stackView.axis = .vertical + stackView.spacing = 14 + scrollView.keyboardDismissMode = .onDrag + scrollView.alwaysBounceVertical = true + + saveBar.backgroundColor = .white + 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() + } + + 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() + } + 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) + } + scrollView.snp.makeConstraints { make in + make.top.equalTo(segmentedControl.snp.bottom).offset(10) + 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 bindActions() { + segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged) + saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyState() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onNavigateBack = { [weak self] in + Task { @MainActor in self?.navigationController?.popViewController(animated: true) } + } + viewModel.onNavigateLog = { [weak self] in + Task { @MainActor in self?.navigationController?.pushViewController(ScenicQueueSettingChangeLogViewController(), animated: true) } + } + } + + 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() + } + } + + 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 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 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(_:))) + 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 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 tipCard() -> UIView { + let card = ScenicQueueSettingCard() + card.backgroundColor = ScenicQueueTokens.bannerBlue + let title = UILabel() + title.text = "排队基础参数" + 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 + card.addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(14) + } + return card + } + + private func actionCard(title: String, value: String, primary: String, secondary: String?, primaryAction: Selector, secondaryAction: Selector?, invalid: Bool = false) -> 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 + card.addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(14) + } + return card + } + + private func presetVoiceCard(index: Int, phrase: String) -> UIView { + let card = ScenicQueueSettingCard() + let label = UILabel() + label.text = phrase + label.font = ScenicQueueTokens.settingsRowTitleFont + 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 + } + + @objc private func tabChanged() { + viewModel.setSettingsTab(ScenicQueueSettingsTab(rawValue: segmentedControl.selectedSegmentIndex) ?? .basic) + } + + @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 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 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 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 customTTSChanged(_ sender: UITextField) { viewModel.setCustomTTSText(sender.text ?? "") } +} + +/// 设置页卡片容器。 +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) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 设置页文本字段行。 +final class ScenicQueueSettingFieldRow: ScenicQueueSettingCard { + let textField = UITextField() + + 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 + 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) + 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) + 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) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 设置页开关行。 +final class ScenicQueueSwitchRow: ScenicQueueSettingCard { + let toggle = UISwitch() + + init(title: String, isOn: Bool) { + super.init() + let label = UILabel() + label.text = title + label.font = ScenicQueueTokens.settingsFieldLabelFont + label.textColor = AppColor.textPrimary + toggle.isOn = isOn + 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) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift new file mode 100644 index 0000000..a4637a3 --- /dev/null +++ b/suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift @@ -0,0 +1,414 @@ +// +// ScenicQueueViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 排队管理首页,严格对齐 Android `ScenicQueueScreen` 的布局与交互。 +final class ScenicQueueViewController: BaseViewController { + private let viewModel = ScenicQueueViewModel() + private let api: ScenicQueueAPIProtocol + + private let punchSpotRow = UIView() + private let punchSpotLabel = UILabel() + private let statsBanner = ScenicQueueStatsBannerView() + private let contentContainer = UIView() + private let segmentedControl = UISegmentedControl(items: ["当前排队", "过号列表"]) + 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 selectedTab = 0 + + @MainActor + init(api: ScenicQueueAPIProtocol? = nil) { + self.api = api ?? NetworkServices.shared.scenicQueueAPI + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + Task { await viewModel.refreshQueueGate(api: api) } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.startQueueStatsPolling(api: api) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + viewModel.stopQueueStatsPolling() + } + + override func setupNavigationBar() { + title = "排队管理" + navigationItem.rightBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "gearshape"), + style: .plain, + target: self, + action: #selector(settingsTapped) + ) + } + + override func setupUI() { + view.backgroundColor = ScenicQueueTokens.pageBackground + + punchSpotRow.backgroundColor = .white + let locationIcon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse")) + locationIcon.tintColor = ScenicQueueTokens.bannerBlue + punchSpotLabel.font = .systemFont(ofSize: 16) + punchSpotLabel.textColor = UIColor.black.withAlphaComponent(0.7) + punchSpotLabel.lineBreakMode = .byTruncatingTail + punchSpotRow.addSubview(locationIcon) + punchSpotRow.addSubview(punchSpotLabel) + + 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) + + 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) + + emptyLabel.textColor = AppColor.textTertiary + emptyLabel.font = ScenicQueueTokens.emptyStateFont + emptyLabel.textAlignment = .center + + bottomBar.backgroundColor = .white + bottomBar.layer.shadowColor = UIColor.black.cgColor + bottomBar.layer.shadowOpacity = 0.12 + bottomBar.layer.shadowRadius = 8 + bottomBar.layer.shadowOffset = CGSize(width: 0, height: -2) + configureBottomButton(prepareCallButton, title: "准备叫号", subtitle: "服务人员操作", image: UIImage(systemName: "play.fill"), color: ScenicQueueTokens.bannerBlue) + configureBottomButton(quickCallButton, title: "快速叫号", subtitle: "叫号人员操作", image: UIImage(systemName: "person.fill"), color: UIColor(hex: 0x7C3AED)) + + view.addSubview(punchSpotRow) + view.addSubview(statsBanner) + view.addSubview(contentContainer) + contentContainer.addSubview(segmentedControl) + contentContainer.addSubview(tableView) + contentContainer.addSubview(emptyLabel) + view.addSubview(bottomBar) + bottomBar.addSubview(prepareCallButton) + bottomBar.addSubview(quickCallButton) + + locationIcon.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(16) + make.centerY.equalToSuperview() + make.size.equalTo(24) + } + punchSpotLabel.snp.makeConstraints { make in + make.leading.equalTo(locationIcon.snp.trailing).offset(6) + make.trailing.equalToSuperview().offset(-16) + make.centerY.equalToSuperview() + } + } + + override func setupConstraints() { + punchSpotRow.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide) + make.leading.trailing.equalToSuperview() + make.height.equalTo(56) + } + statsBanner.snp.makeConstraints { make in + make.top.equalTo(punchSpotRow.snp.bottom) + make.leading.trailing.equalToSuperview() + make.height.equalTo(96) + } + contentContainer.snp.makeConstraints { make in + make.top.equalTo(statsBanner.snp.bottom).offset(16) + 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) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(segmentedControl.snp.bottom).offset(8) + make.leading.trailing.bottom.equalToSuperview() + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + prepareCallButton.snp.makeConstraints { make in + make.top.equalToSuperview().offset(10) + make.leading.equalToSuperview().offset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-10) + make.height.equalTo(68) + } + quickCallButton.snp.makeConstraints { make in + make.top.bottom.width.equalTo(prepareCallButton) + make.leading.equalTo(prepareCallButton.snp.trailing).offset(10) + make.trailing.equalToSuperview().offset(-16) + } + } + + override func bindActions() { + segmentedControl.addTarget(self, action: #selector(tabChanged), for: .valueChanged) + prepareCallButton.addTarget(self, action: #selector(prepareCallTapped), for: .touchUpInside) + quickCallButton.addTarget(self, action: #selector(quickCallTapped), for: .touchUpInside) + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyState() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onRequireSettings = { [weak self] in + Task { @MainActor in self?.presentParametersRequiredDialog() } + } + viewModel.onNavigateSettings = { [weak self] in + Task { @MainActor in self?.navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) } + } + viewModel.onNavigateBack = { [weak self] in + Task { @MainActor in self?.navigationController?.popViewController(animated: true) } + } + NotificationCenter.default.addObserver(self, selector: #selector(handleQueueDidUpdate), name: NotificationName.scenicQueueDidUpdate, object: nil) + } + + private func applyState() { + punchSpotLabel.text = viewModel.selectedPunchSpotLine + statsBanner.configure(summary: viewModel.summary) + tableView.refreshControl?.endRefreshing() + tableView.reloadData() + let isEmpty = selectedTab == 0 ? viewModel.currentQueue.isEmpty : viewModel.skippedQueue.isEmpty + emptyLabel.text = selectedTab == 0 ? "暂无排队" : "暂无过号记录" + emptyLabel.isHidden = !isEmpty + prepareCallButton.isHidden = !viewModel.prepareCallButtonEnabled + quickCallButton.isHidden = !viewModel.quickCallButtonEnabled + prepareCallButton.setImage(UIImage(systemName: viewModel.prepareCallPlaying ? "pause.fill" : "play.fill"), for: .normal) + bottomBar.isHidden = !viewModel.queueGatePassed || (!viewModel.prepareCallButtonEnabled && !viewModel.quickCallButtonEnabled) + } + + private func configureBottomButton(_ button: UIButton, title: String, subtitle: String, image: UIImage?, color: UIColor) { + var config = UIButton.Configuration.filled() + config.baseBackgroundColor = color + config.baseForegroundColor = .white + config.image = image + config.imagePadding = 10 + config.cornerStyle = .medium + var titleAttributes = AttributeContainer() + titleAttributes.font = UIFont.systemFont(ofSize: 15, weight: .semibold) + var subtitleAttributes = AttributeContainer() + subtitleAttributes.font = UIFont.systemFont(ofSize: 11) + subtitleAttributes.foregroundColor = UIColor.white.withAlphaComponent(0.82) + config.attributedTitle = AttributedString(title, attributes: titleAttributes) + config.attributedSubtitle = AttributedString(subtitle, attributes: subtitleAttributes) + button.configuration = config + button.tintColor = .white + } + + 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) + } + + 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 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) + } + + @objc private func settingsTapped() { + viewModel.openSettings() + } + + @objc private func tabChanged() { + selectedTab = segmentedControl.selectedSegmentIndex + tableView.reloadData() + applyState() + } + + @objc private func refreshPulled() { + Task { + if selectedTab == 0 { + await viewModel.refreshCurrentQueue(api: api) + } else { + await viewModel.refreshSkippedQueue(api: api) + } + } + } + + @objc private func prepareCallTapped() { + viewModel.togglePrepareCallPlayback() + } + + @objc private func quickCallTapped() { + Task { await viewModel.quickCallFirstTicket(api: api) } + } + + @objc private func handleQueueDidUpdate() { + Task { + await viewModel.refreshQueueStats(api: api) + await viewModel.refreshCurrentQueue(api: api) + await viewModel.refreshSkippedQueue(api: api) + } + } +} + +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 + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 120 else { return } + Task { + if selectedTab == 0 { + await viewModel.loadMoreCurrentQueue(api: api) + } else { + await viewModel.loadMoreSkippedQueue(api: api) + } + } + } +} + +extension ScenicQueueViewController: ScenicQueueTicketCellDelegate { + func scenicQueueTicketCellDidTapDial(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { + openDial(ticket.dialPhoneDigits) + } + + func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { + viewModel.requestSkipTicket(recordId: ticket.recordId, queueNo: ticket.queueNo) + presentConfirm(title: "确认过号", message: ticket.queueNo) { [weak self] in + guard let self else { return } + Task { await self.viewModel.confirmSkipTicket(api: self.api) } + } + } + + 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 + guard let self else { return } + Task { await self.viewModel.confirmCallTicket(api: self.api) } + } + } else { + viewModel.startShooting(api: api, recordId: ticket.recordId, queueNo: ticket.queueNo) + } + } + + func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { + viewModel.requestCallConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo, isRecall: true) + presentConfirm(title: "重新叫号", message: ticket.queueNo) { [weak self] in + guard let self else { return } + Task { await self.viewModel.confirmCallTicket(api: self.api) } + } + } + + func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) { + viewModel.requestFinishConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo) + presentConfirm(title: "确认完成拍摄", message: ticket.queueNo) { [weak self] in + guard let self else { return } + Task { await self.viewModel.confirmFinishTicket(api: self.api) } + } + } + + 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) + } +} + +extension ScenicQueueViewController: ScenicQueueSkippedCellDelegate { + func scenicQueueSkippedCellDidTapDial(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) { + openDial(ticket.dialPhoneDigits) + } + + func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) { + viewModel.requestRequeueConfirm(recordId: ticket.recordId, queueNo: ticket.queueNo) + presentConfirm(title: "确认重新排队", message: ticket.queueNo) { [weak self] in + guard let self else { return } + Task { await self.viewModel.confirmRequeueTicket(api: self.api) } + } + } +} diff --git a/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift b/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift new file mode 100644 index 0000000..32049aa --- /dev/null +++ b/suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift @@ -0,0 +1,543 @@ +// +// ScenicQueueViews.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 排队统计 Banner。 +final class ScenicQueueStatsBannerView: UIView { + private let peopleValueLabel = UILabel() + private let avgValueLabel = UILabel() + + init() { + super.init(frame: .zero) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 更新统计。 + func configure(summary: ScenicQueueSummary) { + peopleValueLabel.text = "\(summary.peopleInQueue)" + avgValueLabel.text = "\(summary.avgWaitMinutes)" + } + + private func setupUI() { + backgroundColor = ScenicQueueTokens.bannerBlue + + let left = metricColumn(title: "在排人数", valueLabel: peopleValueLabel, unit: "人") + let right = metricColumn(title: "平均等待", valueLabel: avgValueLabel, unit: "分钟") + let divider = UIView() + divider.backgroundColor = ScenicQueueTokens.bannerDivider + + addSubview(left) + addSubview(divider) + addSubview(right) + + left.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview() + make.trailing.equalTo(divider.snp.leading) + make.width.equalTo(right) + } + divider.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview() + make.width.equalTo(0.8) + make.height.equalTo(42) + } + right.snp.makeConstraints { make in + make.leading.equalTo(divider.snp.trailing) + make.trailing.top.bottom.equalToSuperview() + } + } + + private func metricColumn(title: String, valueLabel: UILabel, unit: String) -> UIView { + let container = UIView() + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.textColor = ScenicQueueTokens.bannerMutedLabel + titleLabel.font = ScenicQueueTokens.bannerLabelFont + titleLabel.textAlignment = .center + + valueLabel.textColor = .white + valueLabel.font = ScenicQueueTokens.bannerValueFont + valueLabel.textAlignment = .center + + let unitLabel = UILabel() + unitLabel.text = unit + unitLabel.textColor = .white + unitLabel.font = ScenicQueueTokens.bannerUnitFont + + let valueStack = UIStackView(arrangedSubviews: [valueLabel, unitLabel]) + valueStack.axis = .horizontal + valueStack.alignment = .lastBaseline + valueStack.spacing = 2 + + let stack = UIStackView(arrangedSubviews: [titleLabel, valueStack]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 6 + container.addSubview(stack) + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + } + return container + } +} + +/// 排队列表标签。 +final class ScenicQueueTagLabel: UILabel { + init() { + super.init(frame: .zero) + font = ScenicQueueTokens.statusChipFont + textAlignment = .center + layer.cornerRadius = ScenicQueueTokens.radiusChip + clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 设置标签文案与颜色。 + func configure(text: String, foreground: UIColor, background: UIColor) { + self.text = text + textColor = foreground + backgroundColor = background + } +} + +/// 排队操作按钮。 +final class ScenicQueueActionButton: UIButton { + init(title: String, background: UIColor, foreground: UIColor) { + super.init(frame: .zero) + setTitle(title, for: .normal) + titleLabel?.font = ScenicQueueTokens.buttonPrimaryFont + setTitleColor(foreground, for: .normal) + backgroundColor = background + layer.cornerRadius = ScenicQueueTokens.radiusPrimaryRowButton + clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 当前排队 cell 交互代理。 +protocol ScenicQueueTicketCellDelegate: AnyObject { + func scenicQueueTicketCellDidTapDial(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) + func scenicQueueTicketCellDidTapSkip(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) + func scenicQueueTicketCellDidTapPrimary(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) + func scenicQueueTicketCellDidTapRecall(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) + func scenicQueueTicketCellDidTapComplete(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) + func scenicQueueTicketCellDidTapMark(_ cell: ScenicQueueTicketCell, ticket: ScenicQueueTicket) +} + +/// 当前排队列表 cell。 +final class ScenicQueueTicketCell: UITableViewCell { + static let reuseIdentifier = "ScenicQueueTicketCell" + + weak var delegate: ScenicQueueTicketCellDelegate? + + private let cardView = UIView() + private let queueNoLabel = UILabel() + private let markButton = UIButton(type: .system) + private let tagStack = UIStackView() + private let metaStack = UIStackView() + private let actionsStack = UIStackView() + private let recallButton = ScenicQueueActionButton(title: "重新叫号", background: ScenicQueueTokens.buttonCompleteBackground, foreground: ScenicQueueTokens.bannerBlue) + private let completeButton = ScenicQueueActionButton(title: "完成", background: ScenicQueueTokens.buttonCompleteBackground, foreground: ScenicQueueTokens.bannerBlue) + private var ticket: ScenicQueueTicket? + private var showStartShootingButton = true + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置当前排队 cell。 + func configure(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int, showStartShootingButton: Bool) { + self.ticket = ticket + self.showStartShootingButton = showStartShootingButton + queueNoLabel.text = ticket.queueNo + markButton.isHidden = ticket.markAsPhotog == 1 + rebuildTags(ticket) + rebuildMeta(ticket) + rebuildActions(ticket: ticket, shootingQueueNo: shootingQueueNo, remainSeconds: remainSeconds) + } + + private func setupUI() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = ScenicQueueTokens.radiusCard + cardView.layer.borderWidth = 1 + cardView.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor + cardView.clipsToBounds = true + contentView.addSubview(cardView) + + queueNoLabel.font = ScenicQueueTokens.ticketIdFont + queueNoLabel.textColor = AppColor.textPrimary + + markButton.setTitle("标记", for: .normal) + markButton.titleLabel?.font = ScenicQueueTokens.rowLabelFont + markButton.setTitleColor(ScenicQueueTokens.bannerBlue, for: .normal) + markButton.addTarget(self, action: #selector(markTapped), for: .touchUpInside) + + let titleRow = UIStackView(arrangedSubviews: [queueNoLabel, markButton, UIView(), tagStack]) + titleRow.axis = .horizontal + titleRow.alignment = .center + titleRow.spacing = 4 + tagStack.axis = .horizontal + tagStack.spacing = 6 + tagStack.alignment = .center + + metaStack.axis = .vertical + metaStack.spacing = 10 + + actionsStack.axis = .vertical + actionsStack.spacing = 10 + + let stack = UIStackView(arrangedSubviews: [titleRow, metaStack, actionsStack]) + stack.axis = .vertical + stack.spacing = 12 + cardView.addSubview(stack) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12)) + } + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 16, bottom: 14, right: 16)) + } + } + + private func rebuildTags(_ ticket: ScenicQueueTicket) { + tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + if ticket.isMissRequeue { + tagStack.addArrangedSubview(tag(text: ticket.missRequeueText, fg: ScenicQueueTokens.tagMissRequeueForeground, bg: ScenicQueueTokens.tagMissRequeueBackground)) + } + let statusColors: (UIColor, UIColor) = ticket.status == .waiting + ? (ScenicQueueTokens.tagWaitingForeground, ScenicQueueTokens.tagWaitingBackground) + : (ScenicQueueTokens.tagCalledForeground, ScenicQueueTokens.tagCalledBackground) + tagStack.addArrangedSubview(tag(text: ticket.statusText, fg: statusColors.0, bg: statusColors.1)) + } + + private func rebuildMeta(_ ticket: ScenicQueueTicket) { + metaStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + let identityRow = UIStackView() + identityRow.axis = .horizontal + identityRow.spacing = 6 + if ticket.shouldShowIdentityTag { + let colors = identityColors(ticket.identityTag) + identityRow.addArrangedSubview(tag(text: ticket.identityTag, fg: colors.fg, bg: colors.bg)) + } + if !ticket.queueBanLabel.isEmpty { + identityRow.addArrangedSubview(tag(text: ticket.queueBanLabel, fg: ScenicQueueTokens.tagBanForeground, bg: ScenicQueueTokens.tagBanBackground)) + } + if !identityRow.arrangedSubviews.isEmpty { + metaStack.addArrangedSubview(identityRow) + } + metaStack.addArrangedSubview(kvRow(label: "取号时间", value: ticket.queueTimeDisplay)) + metaStack.addArrangedSubview(kvRow(label: "手机号", value: ticket.phoneMasked, tap: #selector(dialTapped))) + metaStack.addArrangedSubview(kvRow(label: "等待", value: "\(ticket.waitMinutes)分钟", accent: true)) + metaStack.addArrangedSubview(kvRow(label: "前方", value: "\(ticket.peopleAhead)人", accent: true)) + metaStack.addArrangedSubview(kvRow(label: "排号次数", value: "\(ticket.queueCountToday)次", accent: true)) + } + + private func rebuildActions(ticket: ScenicQueueTicket, shootingQueueNo: String?, remainSeconds: Int) { + actionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + 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]) + 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) + } + actionsStack.addArrangedSubview(row) + if ticket.status == .called, shootingQueueNo != ticket.queueNo { + recallButton.addTarget(self, action: #selector(recallTapped), for: .touchUpInside) + recallButton.snp.makeConstraints { make in make.height.equalTo(40) } + actionsStack.addArrangedSubview(recallButton) + } + if ticket.showCompleteAction { + completeButton.addTarget(self, action: #selector(completeTapped), for: .touchUpInside) + completeButton.snp.makeConstraints { make in make.height.equalTo(40) } + actionsStack.addArrangedSubview(completeButton) + } + } + + private func tag(text: String, fg: UIColor, bg: UIColor) -> UILabel { + let label = PaddingLabel(insets: UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8)) + label.font = ScenicQueueTokens.statusChipFont + label.textColor = fg + label.backgroundColor = bg + label.text = text + label.layer.cornerRadius = ScenicQueueTokens.radiusChip + label.clipsToBounds = true + return label + } + + private func identityColors(_ label: String) -> (fg: UIColor, bg: UIColor) { + switch label.trimmingCharacters(in: .whitespacesAndNewlines) { + case "打野": + return (UIColor(hex: 0xF5222D), UIColor(hex: 0xFFF1F0)) + case "摄影师": + return (UIColor(hex: 0x1890FF), UIColor(hex: 0xE6F7FF)) + default: + return (UIColor(hex: 0x4B5563), UIColor(hex: 0xF3F4F6)) + } + } + + private func kvRow(label: String, value: String, accent: Bool = false, tap: Selector? = nil) -> UIView { + let row = UIView() + let left = UILabel() + left.text = label + left.font = ScenicQueueTokens.metaLabelFont + left.textColor = AppColor.textTertiary + let right = PaddingLabel(insets: accent ? UIEdgeInsets(top: 1, left: 6, bottom: 1, right: 6) : .zero) + right.text = value + right.font = accent ? ScenicQueueTokens.metaAccentFont : ScenicQueueTokens.rowLabelFont + right.textColor = accent ? ScenicQueueTokens.bannerBlue : AppColor.textPrimary + 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) + left.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.top.bottom.equalToSuperview() + } + right.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12) + } + return row + } + + @objc private func dialTapped() { + guard let ticket else { return } + delegate?.scenicQueueTicketCellDidTapDial(self, ticket: ticket) + } + + @objc private func skipTapped() { + guard let ticket else { return } + delegate?.scenicQueueTicketCellDidTapSkip(self, ticket: ticket) + } + + @objc private func primaryTapped() { + guard let ticket else { return } + delegate?.scenicQueueTicketCellDidTapPrimary(self, ticket: ticket) + } + + @objc private func recallTapped() { + guard let ticket else { return } + delegate?.scenicQueueTicketCellDidTapRecall(self, ticket: ticket) + } + + @objc private func completeTapped() { + guard let ticket else { return } + delegate?.scenicQueueTicketCellDidTapComplete(self, ticket: ticket) + } + + @objc private func markTapped() { + guard let ticket else { return } + delegate?.scenicQueueTicketCellDidTapMark(self, ticket: ticket) + } +} + +/// 过号列表 cell 代理。 +protocol ScenicQueueSkippedCellDelegate: AnyObject { + func scenicQueueSkippedCellDidTapDial(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) + func scenicQueueSkippedCellDidTapRequeue(_ cell: ScenicQueueSkippedCell, ticket: ScenicQueueSkippedTicket) +} + +/// 过号列表 cell。 +final class ScenicQueueSkippedCell: UITableViewCell { + static let reuseIdentifier = "ScenicQueueSkippedCell" + + weak var delegate: ScenicQueueSkippedCellDelegate? + private let cardView = UIView() + private let queueNoLabel = UILabel() + private let tagStack = UIStackView() + private let metaStack = UIStackView() + private let requeueButton = ScenicQueueActionButton(title: "重新排队", background: ScenicQueueTokens.bannerBlue, foreground: .white) + private var ticket: ScenicQueueSkippedTicket? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 配置过号 cell。 + func configure(ticket: ScenicQueueSkippedTicket) { + self.ticket = ticket + queueNoLabel.text = ticket.queueNo + rebuildTags(ticket) + rebuildMeta(ticket) + } + + private func setupUI() { + selectionStyle = .none + backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = ScenicQueueTokens.radiusCard + cardView.layer.borderWidth = 1 + cardView.layer.borderColor = ScenicQueueTokens.cardOutline.cgColor + contentView.addSubview(cardView) + + queueNoLabel.font = ScenicQueueTokens.ticketIdFont + queueNoLabel.textColor = AppColor.textPrimary + tagStack.axis = .horizontal + tagStack.spacing = 6 + let titleRow = UIStackView(arrangedSubviews: [queueNoLabel, UIView(), tagStack]) + titleRow.axis = .horizontal + titleRow.alignment = .center + + metaStack.axis = .vertical + metaStack.spacing = 10 + requeueButton.addTarget(self, action: #selector(requeueTapped), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [titleRow, metaStack, requeueButton]) + stack.axis = .vertical + stack.spacing = 12 + cardView.addSubview(stack) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 5, left: 12, bottom: 5, right: 12)) + } + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 16, bottom: 14, right: 16)) + } + requeueButton.snp.makeConstraints { make in + make.height.equalTo(46) + } + } + + private func rebuildTags(_ ticket: ScenicQueueSkippedTicket) { + tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + if !ticket.queueBanLabel.isEmpty { + tagStack.addArrangedSubview(tag(text: ticket.queueBanLabel, fg: ScenicQueueTokens.tagBanForeground, bg: ScenicQueueTokens.tagBanBackground)) + } + if ticket.isMissRequeue { + tagStack.addArrangedSubview(tag(text: ticket.missRequeueText, fg: ScenicQueueTokens.tagMissRequeueForeground, bg: ScenicQueueTokens.tagMissRequeueBackground)) + } + tagStack.addArrangedSubview(tag(text: ticket.statusText, fg: ScenicQueueTokens.tagSkippedForeground, bg: ScenicQueueTokens.tagSkippedBackground)) + } + + private func rebuildMeta(_ ticket: ScenicQueueSkippedTicket) { + metaStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + metaStack.addArrangedSubview(kvRow(label: "手机号", value: ticket.phoneMasked, tap: #selector(dialTapped))) + if !ticket.skippedTimeDisplay.isEmpty { + metaStack.addArrangedSubview(kvRow(label: "过号时间", value: ticket.skippedTimeDisplay)) + } + } + + private func tag(text: String, fg: UIColor, bg: UIColor) -> UILabel { + let label = PaddingLabel(insets: UIEdgeInsets(top: 5, left: 8, bottom: 5, right: 8)) + label.text = text + label.font = ScenicQueueTokens.statusChipFont + label.textColor = fg + label.backgroundColor = bg + label.layer.cornerRadius = ScenicQueueTokens.radiusChip + label.clipsToBounds = true + return label + } + + private func kvRow(label: String, value: String, tap: Selector? = nil) -> UIView { + let row = UIView() + let left = UILabel() + left.text = label + left.font = ScenicQueueTokens.metaLabelFont + left.textColor = AppColor.textTertiary + let right = UILabel() + 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) + left.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview() + make.top.bottom.equalToSuperview() + } + right.snp.makeConstraints { make in + make.trailing.centerY.equalToSuperview() + make.leading.greaterThanOrEqualTo(left.snp.trailing).offset(12) + } + return row + } + + @objc private func dialTapped() { + guard let ticket else { return } + delegate?.scenicQueueSkippedCellDidTapDial(self, ticket: ticket) + } + + @objc private func requeueTapped() { + guard let ticket else { return } + delegate?.scenicQueueSkippedCellDidTapRequeue(self, ticket: ticket) + } +} + +/// 带内边距的 UILabel。 +final class PaddingLabel: UILabel { + private let insets: UIEdgeInsets + + init(insets: UIEdgeInsets) { + self.insets = insets + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.inset(by: insets)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + insets.left + insets.right, height: size.height + insets.top + insets.bottom) + } +} diff --git a/suixinkanTests/ScenicQueueFeatureTests.swift b/suixinkanTests/ScenicQueueFeatureTests.swift new file mode 100644 index 0000000..8a66a92 --- /dev/null +++ b/suixinkanTests/ScenicQueueFeatureTests.swift @@ -0,0 +1,433 @@ +// +// ScenicQueueFeatureTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 排队管理同步功能测试。 +final class ScenicQueueFeatureTests: XCTestCase { + + func testModelDecodingAndMapping() throws { + let homeJSON = """ + { + "stats":{"type":1}, + "list":{ + "list":[{ + "id":101, + "queue_code":"A12", + "mobile":"13800138000", + "status":1, + "status_text":"待叫号", + "wait_min":8, + "ahead_count":2, + "is_called":0, + "created_at":"2026-07-07 10:20:00", + "identity_tag":"VIP", + "queue_ban_label":"限制1天", + "queue_time":"2026-07-07 10:20:00", + "queue_count_today":3, + "uid":88, + "mark_as_photog":0, + "mark_as_freelance_photog":1, + "is_miss_requeue":1, + "is_miss_requeue_text":"过号重排" + }], + "total":1, + "page":1, + "page_size":10 + }, + "time":"2026-07-07 10:21:00" + } + """.data(using: .utf8)! + + let home = try JSONDecoder().decode(ScenicQueueHomeData.self, from: homeJSON) + let ticket = try XCTUnwrap(home.list?.list.first?.toCurrentTicket()) + + XCTAssertEqual(ticket.recordId, 101) + XCTAssertEqual(ticket.queueNo, "A12") + XCTAssertEqual(ticket.phoneMasked, "138****8000") + XCTAssertEqual(ticket.dialPhoneDigits, "13800138000") + XCTAssertEqual(ticket.status, .waiting) + XCTAssertTrue(ticket.shouldShowIdentityTag) + XCTAssertEqual(ticket.missRequeueText, "过号重排") + } + + func testSettingDecodingClampsSnapshotAndSocketMessage() throws { + let settingJSON = """ + { + "exists":true, + "setting":{ + "id":7, + "scenic_id":11, + "scenic_spot_id":22, + "scenic_spot_name":"东门打卡点", + "photo_estimate_min":40, + "photo_estimate_sec":70, + "first_notice_threshold_pos":60, + "first_notice_sms_enabled":1, + "first_notice_call_enabled":0, + "second_notice_threshold_pos":2, + "second_notice_sms_enabled":0, + "second_notice_call_enabled":1, + "countdown_broadcast_interval_sec":99, + "countdown_readable_threshold_sec":2, + "queue_distance_meter":10000000, + "queue_take_limit":1000000, + "miss_call_requeue_offset":0, + "show_start_shoot_button":0, + "auto_call_next_count":9, + "business_start_time":"09:30", + "business_end_time":"18:00", + "voice_broadcasts":[{"content":"请提前准备","sort_order":1}], + "status":0, + "remark":"自定义播报" + } + } + """.data(using: .utf8)! + let data = try JSONDecoder().decode(ScenicQueueSettingData.self, from: settingJSON) + let snapshot = try XCTUnwrap(data.setting).toSnapshot(fallback: ScenicQueueSettingsSnapshot()) + + XCTAssertEqual(snapshot.shootMinute, 30) + XCTAssertEqual(snapshot.shootSecond, 59) + XCTAssertEqual(snapshot.firstAheadCount, 50) + XCTAssertEqual(snapshot.broadcastIntervalSec, 50) + XCTAssertEqual(snapshot.countdownThresholdSec, 15) + XCTAssertEqual(snapshot.queueDistanceMeter, 9_999_990) + XCTAssertEqual(snapshot.queueTakeLimit, 999_999) + XCTAssertEqual(snapshot.missCallRequeueOffset, 1) + XCTAssertFalse(snapshot.showStartShootingButton) + XCTAssertEqual(snapshot.autoCallAheadCount, 5) + XCTAssertFalse(snapshot.businessOpen) + + let socketJSON = """ + {"code":100000,"data":{"action":"scenic_queue_ticket_called","params":{"scenic_spot_id":22,"record_id":101,"operator_uid":99,"event_id":"evt-1"}}} + """.data(using: .utf8)! + let message = try JSONDecoder().decode(ScenicQueueSocketMessage.self, from: socketJSON) + XCTAssertTrue(message.isQueueEvent) + XCTAssertEqual(message.data?.params?.eventId, "evt-1") + + let sts = try MockScenicQueueAPI.makeSTS() + XCTAssertEqual(sts.bucket, "vipsky") + XCTAssertEqual(sts.credentials.securityToken, "security-token") + } + + func testSettingsValidationSaveRequestAndPresetLimit() async { + let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.settings")! + defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.settings") + let store = AppStore(defaults: defaults) + store.userId = "u1" + store.accountType = "photog" + store.currentScenicId = 11 + let api = MockScenicQueueAPI() + let tts = MockScenicQueueTTSManager() + let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.save(api: api) + XCTAssertEqual(viewModel.settingsTab, .basic) + XCTAssertTrue(viewModel.highlightedInvalidFieldKeys.contains("punchSpot")) + XCTAssertEqual(messages.last, "请选择打卡点") + + await viewModel.setPunchSpot(ScenicQueuePunchSpotOption(id: "22", label: "东门打卡点"), api: api) + viewModel.setShootMinute(9) + viewModel.setShootSecond(30) + viewModel.setFirstAheadCount(4) + viewModel.toggleFirstSms() + viewModel.toggleSecondPhone() + viewModel.setBroadcastIntervalSec(40) + viewModel.setCountdownThresholdSec(30) + viewModel.setQueueDistanceMeter(500) + viewModel.setQueueTakeLimit(6) + viewModel.setMissCallRequeueOffset(2) + viewModel.setAutoCallAheadCount(3) + viewModel.setCustomTTSText("请准备拍摄") + viewModel.saveCurrentTextAsPresetVoiceLocal() + viewModel.setCustomTTSText("第二条") + viewModel.saveCurrentTextAsPresetVoiceLocal() + await viewModel.save(api: api) + + let request = api.savedSetting + XCTAssertEqual(request?.scenicId, 11) + XCTAssertEqual(request?.scenicSpotId, 22) + XCTAssertEqual(request?.photoEstimateMin, 9) + XCTAssertEqual(request?.photoEstimateSec, 30) + XCTAssertEqual(request?.firstNoticeThresholdPos, 4) + XCTAssertEqual(request?.firstNoticeSmsEnabled, 1) + XCTAssertEqual(request?.secondNoticeCallEnabled, 1) + XCTAssertEqual(request?.countdownBroadcastIntervalSec, 40) + XCTAssertEqual(request?.countdownReadableThresholdSec, 30) + XCTAssertEqual(request?.queueDistanceMeter, 500) + XCTAssertEqual(request?.queueTakeLimit, 6) + XCTAssertEqual(request?.missCallRequeueOffset, 2) + XCTAssertEqual(request?.autoCallNextCount, 3) + XCTAssertEqual(request?.voiceBroadcasts.map(\.content), ["请准备拍摄", "第二条"]) + XCTAssertEqual(store.scenicQueuePunchSpotName, "东门打卡点") + XCTAssertEqual(store.scenicQueueSettingsSnapshot()?.shootMinute, 9) + } + + func testMainViewModelGatePagingActionsTTSAndSocketFiltering() async { + let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.main")! + defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.main") + let store = AppStore(defaults: defaults) + store.userId = "9" + store.accountType = "photog" + store.currentScenicId = 11 + store.scenicQueuePunchSpotId = "22" + store.scenicQueuePunchSpotName = "东门打卡点" + store.saveScenicQueueSettingsSnapshot( + ScenicQueueSettingsSnapshot(showStartShootingButton: true, quickCallButtonEnabled: true, prepareCallButtonEnabled: true) + ) + let api = MockScenicQueueAPI() + api.homePages[1] = .page( + items: [.item(id: 101, queueCode: "A12", isCalled: 0), .item(id: 102, queueCode: "A13", isCalled: 0)], + total: 3, + page: 1 + ) + api.homePages[2] = .page( + items: [.item(id: 102, queueCode: "A13", isCalled: 0), .item(id: 103, queueCode: "A14", isCalled: 0)], + total: 3, + page: 2 + ) + let tts = MockScenicQueueTTSManager() + let socket = ScenicQueueSocketClient() + let viewModel = ScenicQueueViewModel(appStore: store, ttsManager: tts, socketClient: socket) + + await viewModel.refreshQueueGate(api: api) + XCTAssertTrue(viewModel.queueGatePassed) + XCTAssertEqual(viewModel.selectedPunchSpotLine, "东门打卡点") + XCTAssertEqual(viewModel.summary, ScenicQueueSummary(peopleInQueue: 3, avgWaitMinutes: 6)) + XCTAssertEqual(viewModel.currentQueue.map(\.recordId), [101, 102]) + + await viewModel.loadMoreCurrentQueue(api: api) + XCTAssertEqual(viewModel.currentQueue.map(\.recordId), [101, 102, 103]) + + viewModel.requestCallConfirm(recordId: 101, queueNo: "A12") + await viewModel.confirmCallTicket(api: api) + XCTAssertEqual(viewModel.currentQueue.first?.status, .called) + XCTAssertEqual(tts.events.last, "start:A12") + + viewModel.requestSkipTicket(recordId: 101, queueNo: "A12") + await viewModel.confirmSkipTicket(api: api) + XCTAssertFalse(viewModel.currentQueue.contains { $0.recordId == 101 }) + XCTAssertTrue(tts.events.contains("stopQueue")) + + let calledMessage = ScenicQueueSocketMessage( + code: 100000, + data: ScenicQueueSocketData( + action: ScenicQueueSocketAction.ticketCalled.rawValue, + params: ScenicQueueSocketParams(scenicSpotId: 22, recordId: 103, operatorUid: 88, eventId: "evt-remote") + ) + ) + socket.onMessage?(calledMessage) + api.homePages[1] = .page(items: [.item(id: 103, queueCode: "A14", isCalled: 1)], total: 1, page: 1) + await viewModel.refreshCurrentQueue(api: api) + XCTAssertEqual(tts.events.last, "start:A14") + + socket.onMessage?(calledMessage) + await viewModel.refreshCurrentQueue(api: api) + XCTAssertEqual(tts.events.filter { $0 == "start:A14" }.count, 1) + + let ownMessage = ScenicQueueSocketMessage( + code: 100000, + data: ScenicQueueSocketData( + action: ScenicQueueSocketAction.ticketCalled.rawValue, + params: ScenicQueueSocketParams(scenicSpotId: 22, recordId: 103, operatorUid: 9, eventId: "evt-own") + ) + ) + socket.onMessage?(ownMessage) + await viewModel.refreshCurrentQueue(api: api) + XCTAssertEqual(tts.events.filter { $0 == "start:A14" }.count, 1) + } + + func testAppStoreQueueCacheIsAccountScopedAndClearedOnLogout() { + let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.store")! + defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.store") + let store = AppStore(defaults: defaults) + + store.userId = "1" + store.accountType = "photog" + store.scenicQueuePunchSpotId = "22" + store.scenicQueuePunchSpotName = "东门打卡点" + store.scenicQueueRemark = "播报" + store.saveScenicQueueSettingsSnapshot(ScenicQueueSettingsSnapshot(shootMinute: 3)) + store.saveScenicQueuePresetVoices(["a", "b", "c", "d", "e", "f"]) + + store.userId = "2" + store.accountType = "photog" + XCTAssertEqual(store.scenicQueuePunchSpotId, "") + XCTAssertNil(store.scenicQueueSettingsSnapshot()) + + store.userId = "1" + store.accountType = "photog" + XCTAssertEqual(store.scenicQueuePunchSpotId, "22") + XCTAssertEqual(store.scenicQueuePresetVoices(), ["a", "b", "c", "d", "e"]) + store.logout() + store.userId = "1" + store.accountType = "photog" + XCTAssertEqual(store.scenicQueuePunchSpotId, "") + XCTAssertNil(store.scenicQueueSettingsSnapshot()) + } +} + +@MainActor +private final class MockScenicQueueAPI: ScenicQueueAPIProtocol { + var savedSetting: ScenicQueueSaveSettingRequest? + var homePages: [Int: ScenicQueueHomeData] = [:] + + func saveSetting(_ request: ScenicQueueSaveSettingRequest) async throws { + savedSetting = request + } + + func call(recordId: Int64) async throws -> ScenicQueueCallData { + ScenicQueueCallData(id: recordId, status: 2, statusText: "已叫号", calledAt: "2026-07-07 10:30:00") + } + + func pass(recordId: Int64) async throws -> ScenicQueuePassData { + ScenicQueuePassData(id: recordId, status: 3, statusText: "已过号") + } + + func finish(recordId: Int64) async throws -> ScenicQueueFinishData { + ScenicQueueFinishData(id: recordId, status: 4, statusText: "已完成") + } + + func userMark(_ request: ScenicQueueUserMarkRequest) async throws {} + + func requeueInsertBefore(recordId: Int64, operatorId: Int) async throws {} + + func shootQueueQrcode(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueShootQueueQrcodeData { + ScenicQueueShootQueueQrcodeData(qrcodeUrl: nil, scene: nil, page: nil, time: nil) + } + + func setting(scenicId: Int64?, scenicSpotId: Int64?) async throws -> ScenicQueueSettingData { + ScenicQueueSettingData(exists: false, setting: nil, time: nil) + } + + func stats(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueStatsData { + ScenicQueueStatsData(queueCount: 3, avgWaitMin: 5.6, time: "2026-07-07 10:20:00") + } + + func home(scenicId: Int64, scenicSpotId: Int64, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData { + if type == 2 { + return .page(items: [], total: 0, page: page) + } + return homePages[page] ?? .page(items: [], total: 0, page: page) + } + + func settingChangeLog(scenicId: Int64, scenicSpotId: Int64?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData { + ScenicQueueSettingChangeLogData(total: 0, page: page, pageSize: pageSize, list: []) + } + + func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse { + ScenicSpotListResponse(list: [ScenicSpotItem(id: 22, name: "东门打卡点")], total: 1) + } + + func socketToken() async throws -> SocketTokenResponse { + SocketTokenResponse(socketToken: "socket-token") + } + + func aliyunSTS(bucket: String) async throws -> AliyunOSSResponse { + try Self.makeSTS(bucket: bucket) + } + + static func makeSTS(bucket: String = "vipsky") throws -> AliyunOSSResponse { + let json = """ + { + "base_url":"https://vipsky.oss-cn-hangzhou.aliyuncs.com", + "endpoint":"oss-cn-hangzhou.aliyuncs.com", + "region":"cn-hangzhou", + "bucket":"\(bucket)", + "expire_seconds":3600, + "credentials":{ + "access_key_id":"ak", + "access_key_secret":"secret", + "security_token":"security-token" + } + } + """.data(using: .utf8)! + return try JSONDecoder().decode(AliyunOSSResponse.self, from: json) + } +} + +private final class MockScenicQueueTTSManager: ScenicQueueTTSManaging { + private(set) var events: [String] = [] + private(set) var customTextLooping = false + + func prepareQueueTTSEngine(api: ScenicQueueAPI?) async { + events.append("prepare") + } + + func startQueueCallLoop(queueNo: String) { + events.append("start:\(queueNo)") + } + + func stopQueueCallLoop() { + events.append("stopQueue") + } + + func speakPlainTextOnce(_ text: String) async -> Bool { + events.append("speak:\(text)") + return true + } + + func startCustomTextLoop(_ text: String) async -> Bool { + customTextLooping = true + events.append("custom:\(text)") + return true + } + + func stopCustomTextLoop() { + customTextLooping = false + events.append("stopCustom") + } + + func interruptShootingAnnouncement() { + events.append("interrupt") + } + + func estimateAnnouncementDurationMillis(_ text: String) -> Int64 { + 1_000 + } +} + +private extension ScenicQueueHomeData { + static func page(items: [ScenicQueueHomeItem], total: Int, page: Int) -> ScenicQueueHomeData { + ScenicQueueHomeData( + stats: ScenicQueueHomeStats(type: 1), + list: ScenicQueueHomeListBlock(list: items, total: total, page: page, pageSize: 10), + time: "2026-07-07 10:20:00" + ) + } +} + +private extension ScenicQueueHomeItem { + static func item(id: Int64, queueCode: String, isCalled: Int) -> ScenicQueueHomeItem { + ScenicQueueHomeItem( + id: id, + queueCode: queueCode, + mobile: "13800138000", + status: isCalled == 1 ? 2 : 1, + statusText: isCalled == 1 ? "已叫号" : "待叫号", + waitMin: 5, + aheadCount: 1, + isCalled: isCalled, + createdAt: "2026-07-07 10:20:00", + calledAt: isCalled == 1 ? "2026-07-07 10:30:00" : nil, + expiredAt: nil, + finishedAt: nil, + identityTag: "", + queueBanLabel: "", + queueBanDays: nil, + queueTime: "2026-07-07 10:20:00", + queueCountToday: 1, + uid: 88, + markAsPhotog: 0, + markAsFreelancePhotog: 0, + isMissRequeue: 0, + isMissRequeueText: nil + ) + } +}