将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
492 lines
16 KiB
Swift
492 lines
16 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|