Files
suixinkan_uikit/suixinkan/Features/ScenicQueue/Models/ScenicQueueAPIModels.swift
汉秋 6336feff27 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:21:53 +08:00

518 lines
18 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
/// stats Android DTO type 使 0
init(type: Int = 0) {
self.type = type
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0
}
private enum CodingKeys: String, CodingKey {
case type
}
}
/// home
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"
}
}
extension ScenicQueueSettingChangeLogItem {
/// Android
var metadataLine: String {
let time: String
if let createdAt, !createdAt.isEmpty {
let input = DateFormatter()
input.locale = Locale(identifier: "en_US_POSIX")
input.timeZone = TimeZone(identifier: "Asia/Shanghai")
let formats = ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ssXXXXX", "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"]
let parsedDate = formats.lazy.compactMap { format -> Date? in
input.dateFormat = format
return input.date(from: createdAt)
}.first
if let date = parsedDate {
let output = DateFormatter()
output.locale = Locale(identifier: "en_US_POSIX")
output.timeZone = TimeZone(identifier: "Asia/Shanghai")
output.dateFormat = "yyyy-MM-dd HH:mm"
time = output.string(from: date)
} else {
time = String(createdAt.replacingOccurrences(of: "T", with: " ").prefix(16))
}
} else {
time = ""
}
let name = operatorName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "未知" : operatorName.trimmingCharacters(in: .whitespacesAndNewlines)
let tail = operatorPhoneTail.trimmingCharacters(in: .whitespacesAndNewlines)
return tail.isEmpty ? "\(time) \(name)" : "\(time) \(name) 尾号\(tail)"
}
/// 使 summary退 displayText
var cardSummary: String {
let value = summary.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? displayText : value
}
}
///
struct ScenicSpotListResponse: Decodable, Equatable {
let list: [ScenicSpotItem]
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
}
}