新增景区排队管理功能

This commit is contained in:
2026-07-07 15:32:25 +08:00
parent 854a66689f
commit 0aa8b14e1f
20 changed files with 4642 additions and 1 deletions

View 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
}
}

View File

@ -0,0 +1,177 @@
//
// ScenicQueueModels.swift
// suixinkan
//
import Foundation
///
enum ScenicQueueCallStatus: Equatable {
case waiting
case called
}
///
struct ScenicQueueTicket: Hashable {
let recordId: Int64
let queueNo: String
let phoneMasked: String
let dialPhoneDigits: String
let status: ScenicQueueCallStatus
let statusText: String
let waitMinutes: Int
let peopleAhead: Int
let showCompleteAction: Bool
let queueBanLabel: String
let identityTag: String
let queueTimeDisplay: String
let queueCountToday: Int
let uid: Int64
let markAsPhotog: Int
let markAsFreelancePhotog: Int
let isMissRequeue: Bool
let missRequeueText: String
///
var shouldShowIdentityTag: Bool {
let text = identityTag.trimmingCharacters(in: .whitespacesAndNewlines)
return !text.isEmpty && text != "普通用户"
}
///
func markedCalled(statusText newStatusText: String) -> ScenicQueueTicket {
ScenicQueueTicket(
recordId: recordId,
queueNo: queueNo,
phoneMasked: phoneMasked,
dialPhoneDigits: dialPhoneDigits,
status: .called,
statusText: newStatusText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? statusText : newStatusText,
waitMinutes: waitMinutes,
peopleAhead: peopleAhead,
showCompleteAction: true,
queueBanLabel: queueBanLabel,
identityTag: identityTag,
queueTimeDisplay: queueTimeDisplay,
queueCountToday: queueCountToday,
uid: uid,
markAsPhotog: markAsPhotog,
markAsFreelancePhotog: markAsFreelancePhotog,
isMissRequeue: isMissRequeue,
missRequeueText: missRequeueText
)
}
}
///
struct ScenicQueueSkippedTicket: Hashable {
let recordId: Int64
let queueNo: String
let phoneMasked: String
let dialPhoneDigits: String
let statusText: String
let skippedTimeDisplay: String
let queueBanLabel: String
let isMissRequeue: Bool
let missRequeueText: String
}
///
struct ScenicQueueSummary: Equatable {
let peopleInQueue: Int
let avgWaitMinutes: Int
static let empty = ScenicQueueSummary(peopleInQueue: 0, avgWaitMinutes: 0)
}
///
struct ScenicQueuePunchSpotOption: Hashable {
let id: String
let label: String
}
/// UI
struct ScenicQueueSettingsSnapshot: Codable, Equatable {
var shootMinute: Int = 0
var shootSecond: Int = 0
var firstAheadCount: Int = 0
var firstSms: Bool = false
var firstPhone: Bool = false
var secondAheadCount: Int = 0
var secondSms: Bool = false
var secondPhone: Bool = false
var broadcastIntervalSec: Int = 50
var countdownThresholdSec: Int = 15
var queueDistanceMeter: Int = 0
var queueTakeLimit: Int = 0
var missCallRequeueOffset: Int = 1
var showStartShootingButton: Bool = true
var autoCallAheadCount: Int = 0
var quickCallButtonEnabled: Bool = false
var prepareCallButtonEnabled: Bool = false
var businessOpen: Bool = true
var businessStartTime: String = "10:00"
var businessEndTime: String = "20:00"
}
///
struct ScenicQueuePendingSkip: Equatable {
let recordId: Int64
let queueNo: String
}
///
struct ScenicQueuePendingRequeue: Equatable {
let recordId: Int64
let queueNo: String
}
///
struct ScenicQueuePendingFinish: Equatable {
let recordId: Int64
let queueNo: String
}
///
struct ScenicQueuePendingCall: Equatable {
let recordId: Int64
let queueNo: String
let isRecall: Bool
}
///
struct ScenicQueuePendingMark: Equatable {
let uid: Int64
let queueNo: String
let phoneMasked: String
let markAsFreelancePhotog: Int
}
///
enum ScenicQueueNumericFieldKey: String {
case queueDistanceMeter
case queueTakeLimit
case missCallRequeueOffset
case autoCallAheadCount
case shootMinute
case shootSecond
case firstNoticeAhead
case secondNoticeAhead
case broadcastIntervalSec
case countdownReadableThresholdSec
}
/// Tab
enum ScenicQueueSettingsTab: Int, CaseIterable {
case basic
case notify
case voice
var title: String {
switch self {
case .basic: "基础配置"
case .notify: "通知配置"
case .voice: "语音播报"
}
}
}

View File

@ -0,0 +1,45 @@
//
// ScenicQueueSocketModels.swift
// suixinkan
//
import Foundation
/// WebSocket
struct ScenicQueueSocketMessage: Decodable, Equatable {
let code: Int
let data: ScenicQueueSocketData?
///
var isQueueEvent: Bool {
data?.action == ScenicQueueSocketAction.queueUpdated.rawValue
|| data?.action == ScenicQueueSocketAction.ticketCalled.rawValue
}
}
/// WebSocket data
struct ScenicQueueSocketData: Decodable, Equatable {
let action: String
let params: ScenicQueueSocketParams?
}
/// WebSocket params
struct ScenicQueueSocketParams: Decodable, Equatable {
let scenicSpotId: Int64?
let recordId: Int64?
let operatorUid: Int64?
let eventId: String?
enum CodingKeys: String, CodingKey {
case scenicSpotId = "scenic_spot_id"
case recordId = "record_id"
case operatorUid = "operator_uid"
case eventId = "event_id"
}
}
/// WebSocket action
enum ScenicQueueSocketAction: String {
case queueUpdated = "scenic_spot_queue_updated"
case ticketCalled = "scenic_queue_ticket_called"
}