Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,491 @@
//
// 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
}
}

View File

@ -0,0 +1,368 @@
//
// ScenicQueueSettingsModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
struct ScenicQueueSettingData: Decodable, Equatable {
let exists: Bool
let setting: ScenicQueueSettingItem?
let time: String?
enum CodingKeys: String, CodingKey {
case exists
case setting
case time
}
///
init(exists: Bool = false, setting: ScenicQueueSettingItem? = nil, time: String? = nil) {
self.exists = exists
self.setting = setting
self.time = time
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
exists = try container.decodeLossyBool(forKey: .exists) ?? false
setting = try container.decodeIfPresent(ScenicQueueSettingItem.self, forKey: .setting)
time = try container.decodeLossyString(forKey: .time)
}
}
///
struct ScenicQueueSettingItem: Decodable, Equatable {
let id: Int64?
let scenicId: Int?
let scenicSpotId: Int?
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 status: Int
let remark: String
let voiceBroadcasts: [ScenicQueueVoiceBroadcastItem]
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 status
case remark
case voiceBroadcasts = "voice_broadcasts"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try container.decodeLossyInt(forKey: .id)).map(Int64.init)
scenicId = try container.decodeLossyInt(forKey: .scenicId)
scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId)
scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName)
photoEstimateMin = try container.decodeLossyInt(forKey: .photoEstimateMin) ?? 0
photoEstimateSec = try container.decodeLossyInt(forKey: .photoEstimateSec) ?? 0
firstNoticeThresholdPos = try container.decodeLossyInt(forKey: .firstNoticeThresholdPos) ?? 0
firstNoticeSmsEnabled = try container.decodeLossyInt(forKey: .firstNoticeSmsEnabled) ?? 0
firstNoticeCallEnabled = try container.decodeLossyInt(forKey: .firstNoticeCallEnabled) ?? 0
secondNoticeThresholdPos = try container.decodeLossyInt(forKey: .secondNoticeThresholdPos) ?? 0
secondNoticeSmsEnabled = try container.decodeLossyInt(forKey: .secondNoticeSmsEnabled) ?? 0
secondNoticeCallEnabled = try container.decodeLossyInt(forKey: .secondNoticeCallEnabled) ?? 0
countdownBroadcastIntervalSec = try container.decodeLossyInt(forKey: .countdownBroadcastIntervalSec) ?? 50
countdownReadableThresholdSec = try container.decodeLossyInt(forKey: .countdownReadableThresholdSec) ?? 15
queueDistanceMeter = try container.decodeLossyInt(forKey: .queueDistanceMeter) ?? 0
queueTakeLimit = try container.decodeLossyInt(forKey: .queueTakeLimit) ?? 0
missCallRequeueOffset = try container.decodeLossyInt(forKey: .missCallRequeueOffset) ?? 1
showStartShootButton = try container.decodeLossyInt(forKey: .showStartShootButton)
autoCallNextCount = try container.decodeLossyInt(forKey: .autoCallNextCount)
businessStartTime = try container.decodeLossyString(forKey: .businessStartTime)
businessEndTime = try container.decodeLossyString(forKey: .businessEndTime)
status = try container.decodeLossyInt(forKey: .status) ?? 1
remark = try container.decodeLossyString(forKey: .remark)
voiceBroadcasts = (try? container.decodeIfPresent([ScenicQueueVoiceBroadcastItem].self, forKey: .voiceBroadcasts)) ?? []
createdAt = try container.decodeLossyString(forKey: .createdAt)
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
}
}
///
struct ScenicQueueVoiceBroadcastItem: Codable, Identifiable, Equatable {
var id: String { "\(sortOrder)-\(content)" }
let content: String
let sortOrder: Int
enum CodingKeys: String, CodingKey {
case content
case sortOrder = "sort_order"
}
///
init(content: String, sortOrder: Int) {
self.content = content
self.sortOrder = sortOrder
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
content = try container.decodeLossyString(forKey: .content).trimmingCharacters(in: .whitespacesAndNewlines)
sortOrder = try container.decodeLossyInt(forKey: .sortOrder) ?? 0
}
}
///
struct ScenicQueueSaveSettingRequest: Encodable, Equatable {
let scenicId: Int
let scenicSpotId: Int
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 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
}
///
init(total: Int = 0, page: Int = 1, pageSize: Int = 20, list: [ScenicQueueSettingChangeLogItem] = []) {
self.total = total
self.page = page
self.pageSize = pageSize
self.list = list
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = try container.decodeLossyInt(forKey: .total) ?? 0
page = try container.decodeLossyInt(forKey: .page) ?? 1
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 20
list = (try? container.decodeIfPresent([ScenicQueueSettingChangeLogItem].self, forKey: .list)) ?? []
}
}
///
struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable {
let id: Int64
let scenicSpotName: String
let operatorName: String
let operatorPhoneTail: String
let summary: String
let displayText: String
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case scenicSpotName = "scenic_spot_name"
case operatorName = "operator_name"
case operatorPhoneTail = "operator_phone_tail"
case summary
case displayText = "display_text"
case createdAt = "created_at"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName)
operatorName = try container.decodeLossyString(forKey: .operatorName)
operatorPhoneTail = try container.decodeLossyString(forKey: .operatorPhoneTail)
summary = try container.decodeLossyString(forKey: .summary)
displayText = try container.decodeLossyString(forKey: .displayText)
createdAt = try container.decodeLossyString(forKey: .createdAt)
}
}
///
struct ScenicQueueShootQueueQRCodeData: Decodable, Equatable {
let qrcodeUrl: String
enum CodingKeys: String, CodingKey {
case qrcodeUrl = "qrcode_url"
}
///
init(qrcodeUrl: String = "") {
self.qrcodeUrl = qrcodeUrl
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
qrcodeUrl = try container.decodeLossyString(forKey: .qrcodeUrl)
}
}
/// 线
struct ScenicQueueSettingsSnapshot: Codable, Equatable {
var shootMinute: Int = 0
var shootSecond: Int = 30
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"
enum CodingKeys: String, CodingKey {
case shootMinute = "shoot_minute"
case shootSecond = "shoot_second"
case firstAheadCount = "first_ahead_count"
case firstSms = "first_sms"
case firstPhone = "first_phone"
case secondAheadCount = "second_ahead_count"
case secondSms = "second_sms"
case secondPhone = "second_phone"
case broadcastIntervalSec = "broadcast_interval_sec"
case countdownThresholdSec = "countdown_threshold_sec"
case queueDistanceMeter = "queue_distance_meter"
case queueTakeLimit = "queue_take_limit"
case missCallRequeueOffset = "miss_call_requeue_offset"
case showStartShootingButton = "show_start_shooting_button"
case autoCallAheadCount = "auto_call_ahead_count"
case quickCallButtonEnabled = "quick_call_button_enabled"
case prepareCallButtonEnabled = "prepare_call_button_enabled"
case businessOpen = "business_open"
case businessStartTime = "business_start_time"
case businessEndTime = "business_end_time"
}
}
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 decodeLossyBool(forKey key: Key) throws -> Bool? {
if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value }
if let value = try? decodeIfPresent(Int.self, forKey: key) { return value != 0 }
if let value = try? decodeIfPresent(String.self, forKey: key),
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!text.isEmpty {
if ["1", "true", "yes"].contains(text) { return true }
if ["0", "false", "no"].contains(text) { return false }
}
return nil
}
}

View File

@ -0,0 +1,172 @@
//
// ScenicQueueSettingsStore.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
/// key
enum ScenicQueueLocalSettings {
static let selectedSpotIdKey = "scenic_queue_selected_spot_id"
static let selectedSpotNameKey = "scenic_queue_selected_spot_name"
static let customTtsTextKey = "scenic_queue_custom_tts_text"
static let settingsSnapshotKey = "scenic_queue_settings_snapshot"
static let photoEstimateSecondsKey = "scenic_queue_photo_estimate_seconds"
static let broadcastIntervalSecondsKey = "scenic_queue_broadcast_interval_seconds"
static let countdownThresholdSecondsKey = "scenic_queue_countdown_threshold_seconds"
static let showStartShootingButtonKey = "scenic_queue_show_start_shooting_button"
static let autoCallAheadCountKey = "scenic_queue_auto_call_ahead_count"
static let quickCallButtonEnabledKey = "scenic_queue_quick_call_button_enabled"
static let prepareCallButtonEnabledKey = "scenic_queue_prepare_call_button_enabled"
static let presetVoicesKey = "scenic_queue_preset_voices"
static let ttsEnabledKey = "scenic_queue_tts_enabled"
static let backgroundPollEnabledKey = "scenic_queue_background_poll_enabled"
}
/// key
enum ScenicQueueSettingsStore {
/// key
static func scopedKey(base: String, userId: String?, scenicId: Int?) -> String {
guard let scenicId else { return base }
let user = normalizedUserId(userId)
return "\(base)_user_\(user)_scenic_\(scenicId)"
}
/// key
static func scopedSpotKey(base: String, userId: String?, scenicId: Int?, spotId: Int?) -> String {
guard let scenicId, let spotId else { return scopedKey(base: base, userId: userId, scenicId: scenicId) }
let user = normalizedUserId(userId)
return "\(base)_user_\(user)_scenic_\(scenicId)_spot_\(spotId)"
}
/// ID
static func selectedSpotId(userId: String?, scenicId: Int?) -> Int? {
guard let scenicId else { return legacyPositiveInt(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) }
let key = scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: userId, scenicId: scenicId)
if let value = positiveInt(forKey: key) { return value }
if let legacy = legacyPositiveInt(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) {
UserDefaults.standard.set(legacy, forKey: key)
return legacy
}
return nil
}
///
static func selectedSpotName(userId: String?, scenicId: Int?) -> String {
guard let scenicId else {
return UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.selectedSpotNameKey) ?? ""
}
let key = scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: userId, scenicId: scenicId)
if let value = UserDefaults.standard.string(forKey: key), !value.isEmpty { return value }
let legacy = UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.selectedSpotNameKey) ?? ""
if !legacy.isEmpty {
UserDefaults.standard.set(legacy, forKey: key)
}
return legacy
}
///
static func saveSelectedSpot(id: Int, name: String, userId: String?, scenicId: Int) {
let defaults = UserDefaults.standard
defaults.set(id, forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
defaults.set(name, forKey: ScenicQueueLocalSettings.selectedSpotNameKey)
defaults.set(id, forKey: scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: userId, scenicId: scenicId))
defaults.set(name, forKey: scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: userId, scenicId: scenicId))
}
///
static func customTtsText(userId: String?, scenicId: Int?, spotId: Int?) -> String {
guard let scenicId, let spotId else {
return UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.customTtsTextKey) ?? ""
}
let key = scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: userId, scenicId: scenicId, spotId: spotId)
if let value = UserDefaults.standard.string(forKey: key) { return value }
let legacy = UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.customTtsTextKey) ?? ""
if !legacy.isEmpty {
UserDefaults.standard.set(legacy, forKey: key)
}
return legacy
}
///
static func saveCustomTtsText(_ text: String, userId: String?, scenicId: Int?, spotId: Int?) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
UserDefaults.standard.set(trimmed, forKey: ScenicQueueLocalSettings.customTtsTextKey)
if let scenicId, let spotId {
UserDefaults.standard.set(
trimmed,
forKey: scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: userId, scenicId: scenicId, spotId: spotId)
)
}
}
///
static func settingsSnapshot(userId: String?, scenicId: Int, spotId: Int) -> ScenicQueueSettingsSnapshot? {
let keys = [
scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: userId, scenicId: scenicId, spotId: spotId),
"scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.settingsSnapshotKey)",
"scenic_\(scenicId)_\(ScenicQueueLocalSettings.settingsSnapshotKey)"
]
for key in keys {
guard let data = UserDefaults.standard.data(forKey: key),
let snapshot = try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data)
else { continue }
return snapshot
}
return nil
}
///
static func saveSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot, userId: String?, scenicId: Int, spotId: Int) {
guard let data = try? JSONEncoder().encode(snapshot) else { return }
UserDefaults.standard.set(
data,
forKey: scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: userId, scenicId: scenicId, spotId: spotId)
)
UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.settingsSnapshotKey)")
}
///
static func presetVoices(userId: String?, scenicId: Int?, spotId: Int?) -> [String] {
guard let scenicId, let spotId else { return [] }
let keys = [
scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: userId, scenicId: scenicId, spotId: spotId),
"scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)"
]
for key in keys {
guard let data = UserDefaults.standard.data(forKey: key),
let voices = try? JSONDecoder().decode([String].self, from: data)
else { continue }
return voices
}
return []
}
///
static func savePresetVoices(_ voices: [String], userId: String?, scenicId: Int, spotId: Int) {
let normalized = Array(voices.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }.prefix(5))
guard let data = try? JSONEncoder().encode(normalized) else { return }
UserDefaults.standard.set(
data,
forKey: scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: userId, scenicId: scenicId, spotId: spotId)
)
UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)")
}
private static func normalizedUserId(_ userId: String?) -> String {
let text = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return text.isEmpty ? "anonymous" : text
}
private static func positiveInt(forKey key: String) -> Int? {
guard UserDefaults.standard.object(forKey: key) != nil else { return nil }
let value = UserDefaults.standard.integer(forKey: key)
return value > 0 ? value : nil
}
private static func legacyPositiveInt(forKey key: String) -> Int? {
positiveInt(forKey: key)
}
}