重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
518 lines
18 KiB
Swift
518 lines
18 KiB
Swift
//
|
||
// 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
|
||
}
|
||
}
|