新增景区排队管理功能
This commit is contained in:
@ -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)
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
215
suixinkan/Features/ScenicQueue/API/ScenicQueueAPI.swift
Normal file
215
suixinkan/Features/ScenicQueue/API/ScenicQueueAPI.swift
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
466
suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift
Normal file
466
suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift
Normal file
@ -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>) -> 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
|
||||
}
|
||||
}
|
||||
177
suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift
Normal file
177
suixinkan/Features/ScenicQueue/Models/ScenicQueueModels.swift
Normal file
@ -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: "语音播报"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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:
|
||||
"未授予权限,无法将小程序码保存到相册"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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<Void, Never>?
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
@ -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<String> = []
|
||||
|
||||
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<String>) {
|
||||
var messages: [String] = []
|
||||
var keys: Set<String> = []
|
||||
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<String>) -> 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>) -> Int {
|
||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmptyTrimmed: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
@ -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<Int64> = []
|
||||
private var handledRemoteCallEventIds: Set<String> = []
|
||||
private var shootingTask: Task<Void, Never>?
|
||||
private var prepareCallTask: Task<Void, Never>?
|
||||
|
||||
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<Item, Key: Hashable>(existing: [Item], incoming: [Item], key: KeyPath<Item, Key>) -> [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>) -> Int {
|
||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,7 @@
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要访问相册以选择头像、身份证和银行卡照片</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要保存收款二维码到相册</string>
|
||||
<string>需要保存收款二维码和排队打卡点小程序码到相册</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
69
suixinkan/Theme/Modules/ScenicQueueTokens.swift
Normal file
69
suixinkan/Theme/Modules/ScenicQueueTokens.swift
Normal file
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
427
suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift
Normal file
427
suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
414
suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift
Normal file
414
suixinkan/UI/ScenicQueue/ScenicQueueViewController.swift
Normal file
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
543
suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift
Normal file
543
suixinkan/UI/ScenicQueue/Views/ScenicQueueViews.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user