新增景区排队管理功能
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user