// // ScenicQueueModels.swift // suixinkan // // Created by Codex on 2026/6/25. // import Foundation /// 排队统计响应。 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 } /// 创建排队统计,主要用于空响应兜底。 init(queueCount: Int = 0, avgWaitMin: Double = 0, time: String = "") { self.queueCount = queueCount self.avgWaitMin = avgWaitMin self.time = time } /// 宽松解码统计字段。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) queueCount = try container.decodeLossyInt(forKey: .queueCount) ?? 0 avgWaitMin = try container.decodeLossyDouble(forKey: .avgWaitMin) ?? 0 time = try container.decodeLossyString(forKey: .time) } } /// 排队首页响应,兼容 list 为数组或分页对象两种形态。 struct ScenicQueueHomeData: Decodable, Equatable { let stats: ScenicQueueHomeStats? let list: ScenicQueueHomeListBlock? let time: String? enum CodingKeys: String, CodingKey { case stats case list case time } /// 创建排队首页数据,主要用于空响应兜底。 init(stats: ScenicQueueHomeStats? = nil, list: ScenicQueueHomeListBlock? = nil, time: String? = nil) { self.stats = stats self.list = list self.time = time } /// 宽松解码排队首页数据。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) stats = try container.decodeIfPresent(ScenicQueueHomeStats.self, forKey: .stats) if let block = try? container.decodeIfPresent(ScenicQueueHomeListBlock.self, forKey: .list) { list = block } else if let tickets = try? container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) { list = ScenicQueueHomeListBlock(list: tickets) } else { list = nil } time = try container.decodeIfPresent(String.self, forKey: .time) } } /// 排队首页统计块。 struct ScenicQueueHomeStats: Decodable, Equatable { let type: Int enum CodingKeys: String, CodingKey { case type } /// 创建统计块。 init(type: Int = 0) { self.type = type } /// 宽松解码统计块。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) type = try container.decodeLossyInt(forKey: .type) ?? 0 } } /// 排队列表分页块。 struct ScenicQueueHomeListBlock: Decodable, Equatable { let list: [ScenicQueueTicket] let total: Int let page: Int let pageSize: Int enum CodingKeys: String, CodingKey { case list case total case page case pageSize = "page_size" } /// 创建分页块。 init(list: [ScenicQueueTicket] = [], total: Int? = nil, page: Int = 1, pageSize: Int = 20) { self.list = list self.total = total ?? list.count self.page = page self.pageSize = pageSize } /// 宽松解码分页块。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) list = try container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) ?? [] total = try container.decodeLossyInt(forKey: .total) ?? list.count page = try container.decodeLossyInt(forKey: .page) ?? 1 pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? max(list.count, 20) } } /// 单个排队取号记录。 struct ScenicQueueTicket: Decodable, Equatable, Identifiable { 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 queueBanLabel: String let identityTag: String let queueTime: String let queueCountToday: Int let uid: Int64 let markAsPhotog: Int let markAsFreelancePhotog: Int let isMissRequeue: Int let missRequeueText: String var phoneMasked: String { let digits = mobile.filter(\.isNumber) if mobile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "--" } guard digits.count >= 7 else { return mobile } return "\(digits.prefix(3))****\(digits.suffix(4))" } var dialPhoneDigits: String { let digits = mobile.filter(\.isNumber) return digits.isEmpty ? mobile.filter { !$0.isWhitespace } : digits } var queueTimeDisplay: String { Self.formatQueueTime(queueTime.nonEmptyOrDefault(createdAt)) } var skippedTimeDisplay: String { Self.formatQueueTime(expiredAt.nonEmptyOrDefault(calledAt.nonEmptyOrDefault(createdAt))) } var shouldShowIdentityTag: Bool { let text = identityTag.trimmingCharacters(in: .whitespacesAndNewlines) return !text.isEmpty && text != "普通用户" } var isMissRequeueRecord: Bool { isMissRequeue == 1 } 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 queueBanLabel = "queue_ban_label" case identityTag = "identity_tag" 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 missRequeueText = "is_miss_requeue_text" } enum AlternateCodingKeys: String, CodingKey { case queueNo = "queue_no" case queueNumber = "queue_number" case code case phone case statusName = "status_name" } /// 创建取号记录,主要用于本地状态替换和测试。 init( id: Int64, queueCode: String, mobile: String, status: Int, statusText: String, waitMin: Int, aheadCount: Int, isCalled: Int, createdAt: String, calledAt: String, expiredAt: String, finishedAt: String, queueBanLabel: String = "", identityTag: String = "", queueTime: String = "", queueCountToday: Int = 0, uid: Int64 = 0, markAsPhotog: Int = 0, markAsFreelancePhotog: Int = 0, isMissRequeue: Int = 0, missRequeueText: String = "" ) { self.id = id self.queueCode = queueCode self.mobile = mobile self.status = status self.statusText = statusText self.waitMin = waitMin self.aheadCount = aheadCount self.isCalled = isCalled self.createdAt = createdAt self.calledAt = calledAt self.expiredAt = expiredAt self.finishedAt = finishedAt self.queueBanLabel = queueBanLabel self.identityTag = identityTag self.queueTime = queueTime self.queueCountToday = queueCountToday self.uid = uid self.markAsPhotog = markAsPhotog self.markAsFreelancePhotog = markAsFreelancePhotog self.isMissRequeue = isMissRequeue self.missRequeueText = missRequeueText } /// 宽松解码取号记录,兼容 Android 历史字段别名。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let alternate = try decoder.container(keyedBy: AlternateCodingKeys.self) id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) queueCode = try container.decodeLossyString(forKey: .queueCode) .nonEmptyOrDefault( try alternate.decodeLossyString(forKey: .queueNo) .nonEmptyOrDefault( try alternate.decodeLossyString(forKey: .queueNumber) .nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .code)) ) ) mobile = try container.decodeLossyString(forKey: .mobile) .nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .phone)) status = try container.decodeLossyInt(forKey: .status) ?? 0 statusText = try container.decodeLossyString(forKey: .statusText) .nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .statusName)) waitMin = try container.decodeLossyInt(forKey: .waitMin) ?? 0 aheadCount = try container.decodeLossyInt(forKey: .aheadCount) ?? 0 isCalled = try container.decodeLossyInt(forKey: .isCalled) ?? 0 createdAt = try container.decodeLossyString(forKey: .createdAt) calledAt = try container.decodeLossyString(forKey: .calledAt) expiredAt = try container.decodeLossyString(forKey: .expiredAt) finishedAt = try container.decodeLossyString(forKey: .finishedAt) queueBanLabel = try container.decodeLossyString(forKey: .queueBanLabel).trimmingCharacters(in: .whitespacesAndNewlines) identityTag = try container.decodeLossyString(forKey: .identityTag).trimmingCharacters(in: .whitespacesAndNewlines) queueTime = try container.decodeLossyString(forKey: .queueTime) queueCountToday = max(try container.decodeLossyInt(forKey: .queueCountToday) ?? 0, 0) uid = Int64(try container.decodeLossyInt(forKey: .uid) ?? 0) markAsPhotog = try container.decodeLossyInt(forKey: .markAsPhotog) ?? 0 markAsFreelancePhotog = try container.decodeLossyInt(forKey: .markAsFreelancePhotog) ?? 0 isMissRequeue = try container.decodeLossyInt(forKey: .isMissRequeue) ?? 0 missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines) } private static func formatQueueTime(_ raw: String) -> String { let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return "--" } if text.range(of: #"^\d{2}-\d{2}\s+\d{2}:\d{2}$"#, options: .regularExpression) != nil { return text } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] { formatter.dateFormat = format if let date = formatter.date(from: text) { formatter.dateFormat = "MM-dd HH:mm" return formatter.string(from: date) } } return text } } /// 排队动作请求体。 struct ScenicQueueActionRequest: Encodable { let id: Int64 } /// socket token 响应。 struct SocketTokenResponse: Decodable, Equatable { let socketToken: String enum CodingKeys: String, CodingKey { case socketToken = "socket_token" } /// 创建 socket token 响应。 init(socketToken: String = "") { self.socketToken = socketToken } /// 宽松解码 socket token。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) socketToken = try container.decodeLossyString(forKey: .socketToken) } } /// 排队动作响应。 struct ScenicQueueActionData: 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" } /// 创建动作响应。 init(id: Int64 = 0, status: Int = 0, statusText: String = "", calledAt: String? = nil) { self.id = id self.status = status self.statusText = statusText self.calledAt = calledAt } /// 宽松解码动作响应。 init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) status = try container.decodeLossyInt(forKey: .status) ?? 0 statusText = try container.decodeLossyString(forKey: .statusText) calledAt = try container.decodeIfPresent(String.self, forKey: .calledAt) } } typealias ScenicQueueCallData = ScenicQueueActionData typealias ScenicQueuePassData = ScenicQueueActionData typealias ScenicQueueFinishData = ScenicQueueActionData typealias QueueItem = ScenicQueueTicket /// 重新排队请求体。 struct ScenicQueueRequeueInsertBeforeRequest: Encodable { 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" } } /// 排队列表类型。 enum QueueListType: Int, CaseIterable, Identifiable { case queueing = 1 case passed = 2 var id: Int { rawValue } /// 分段标题。 var title: String { switch self { case .queueing: return "当前排队" case .passed: return "过号列表" } } /// 空态文案。 var emptyText: String { switch self { case .queueing: return "暂无排队" case .passed: return "暂无过号" } } } /// 远端叫号提示。 struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable { let id: Int64 let queueCode: String } private extension KeyedDecodingContainer { func decodeLossyString(forKey key: Key) throws -> String { if let value = try? decodeIfPresent(String.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "1" : "0" } return "" } func decodeLossyInt(forKey key: Key) throws -> Int? { if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } if let value = try? decodeIfPresent(String.self, forKey: key), case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return Int(text) ?? Int(Double(text) ?? 0) } if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? 1 : 0 } return nil } func decodeLossyDouble(forKey key: Key) throws -> Double? { if let value = try? decodeIfPresent(Double.self, forKey: key) { return value } if let value = try? decodeIfPresent(Int.self, forKey: key) { return Double(value) } if let value = try? decodeIfPresent(String.self, forKey: key), case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return Double(text) } return nil } } private extension String { func nonEmptyOrDefault(_ fallback: String) -> String { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? fallback : text } }