Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
199
suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift
Normal file
199
suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift
Normal file
@ -0,0 +1,199 @@
|
||||
//
|
||||
// ScenicQueueAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。
|
||||
@MainActor
|
||||
protocol ScenicQueueServing: AnyObject {
|
||||
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData
|
||||
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData
|
||||
func socketToken() async throws -> SocketTokenResponse
|
||||
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData
|
||||
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData
|
||||
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData
|
||||
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws
|
||||
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws
|
||||
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData
|
||||
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
|
||||
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData
|
||||
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 排队管理 API,封装 `/api/app/scenic-queue` 下的排队接口。
|
||||
final class ScenicQueueAPI {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化排队管理 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取当前打卡点排队统计。
|
||||
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/scenic-queue/queue-stats",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))
|
||||
]
|
||||
)
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueStatsData()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前打卡点排队列表。
|
||||
func scenicQueueHome(
|
||||
scenicId: Int,
|
||||
scenicSpotId: Int,
|
||||
type: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ScenicQueueHomeData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/scenic-queue/home",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)),
|
||||
URLQueryItem(name: "type", value: String(type)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
)
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueHomeData()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 WebSocket 订阅 token。
|
||||
func socketToken() async throws -> SocketTokenResponse {
|
||||
try await client.send(APIRequest(method: .get, path: "/api/app/socket-token"))
|
||||
}
|
||||
|
||||
/// 叫号。
|
||||
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/call", body: ScenicQueueActionRequest(id: id))
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueCallData(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记过号。
|
||||
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/pass", body: ScenicQueueActionRequest(id: id))
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueuePassData(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 标记完成。
|
||||
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData {
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/finish", body: ScenicQueueActionRequest(id: id))
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueFinishData(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 将过号记录重新插回当前排队队列。
|
||||
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/scenic-queue/requeue-insert-before",
|
||||
body: ScenicQueueRequeueInsertBeforeRequest(recordId: recordId, operatorId: operatorId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 标记用户身份/限制排队。
|
||||
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/user-mark", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取排队设置。
|
||||
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData {
|
||||
var query = [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
if let scenicSpotId {
|
||||
query.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)))
|
||||
}
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/scenic-queue/setting", queryItems: query)
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueSettingData()
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存排队设置。
|
||||
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/scenic-queue/save-setting", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取取号二维码。
|
||||
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/scenic-queue/shoot-queue-qrcode",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取排队设置变更日志。
|
||||
func scenicQueueSettingChangeLog(
|
||||
scenicId: Int,
|
||||
scenicSpotId: Int?,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20
|
||||
) async throws -> ScenicQueueSettingChangeLogData {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1)))
|
||||
]
|
||||
if let scenicSpotId {
|
||||
query.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)))
|
||||
}
|
||||
do {
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/scenic-queue/setting-change-log", queryItems: query)
|
||||
)
|
||||
} catch APIError.emptyData {
|
||||
return ScenicQueueSettingChangeLogData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicQueueAPI: ScenicQueueServing {}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
20
suixinkan_ios/Features/QueueManagement/QueueManagement.md
Normal file
20
suixinkan_ios/Features/QueueManagement/QueueManagement.md
Normal file
@ -0,0 +1,20 @@
|
||||
# 排队管理模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`Features/QueueManagement` 承接首页 `/scenic-queue` 和 `queue_management` 权限入口,负责景区打卡点队列列表、叫号、过号、完成、重新排队、用户标记、队列设置、排队二维码和配置日志。
|
||||
|
||||
## 代码结构
|
||||
|
||||
- `ScenicQueueAPI`:封装 `/api/app/scenic-queue/...` 和 `/api/app/socket-token`。
|
||||
- `QueueManagementViewModel`:管理已保存打卡点门禁、当前排队/过号列表分页、统计、队列动作和页面前台实时监听。
|
||||
- `ScenicQueueSettingsViewModel`:管理打卡点设置加载、本地快照兜底、表单校验、保存、二维码和配置日志。
|
||||
- `ScenicQueueSettingsStore`:按 `userId + scenicId + spotId` 作用域保存本地排队设置,并兼容旧 key。
|
||||
- `Core/Queue`:承接排队 WebSocket、远端叫号去重、语音播报和页面离开后的短轮询运行时。
|
||||
|
||||
## 业务边界
|
||||
|
||||
- 列表页从当前账号景区和打卡点上下文读取数据;没有已保存打卡点时只显示设置引导,不自动选择第一个打卡点。
|
||||
- 叫号成功后只做本地已叫号标记;过号、完成、重新排队和用户标记成功后刷新当前打卡点列表。
|
||||
- 页面可见时由 `QueueManagementViewModel` 维护 WebSocket 监听;页面离开后由 `ScenicQueueRuntime` 按本地开关进行短轮询。
|
||||
- 设置保存前校验拍摄时长、通知阈值、播报间隔、读秒阈值、排队范围、排队次数、过号顺延和自定义播报长度。
|
||||
@ -0,0 +1,87 @@
|
||||
//
|
||||
// QueueManagementViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 排队管理页。
|
||||
final class QueueManagementViewController: ModuleTableViewController {
|
||||
private let viewModel = QueueManagementViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "排队管理"
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "设置",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(openSettings)
|
||||
)
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { [weak self] in self?.updateTitle() }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.items.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let item = viewModel.items[indexPath.row]
|
||||
cell.configure(title: item.phoneMasked, subtitle: item.statusText, detail: item.queueCode)
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.reload(
|
||||
api: services.scenicQueueAPI,
|
||||
scenicId: services.currentScenicId,
|
||||
userId: services.userId,
|
||||
spots: services.scenicSpotContext.spots
|
||||
)
|
||||
updateTitle()
|
||||
}
|
||||
|
||||
private func updateTitle() {
|
||||
title = "排队 \(viewModel.queueCount)"
|
||||
}
|
||||
|
||||
@objc private func openSettings() {
|
||||
navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension QueueManagementViewModel: ViewModelBindable {}
|
||||
|
||||
/// 排队设置页。
|
||||
final class ScenicQueueSettingsViewController: ModuleTableViewController {
|
||||
private let viewModel = ScenicQueueSettingsViewModel()
|
||||
|
||||
override func viewDidLoad() {
|
||||
title = "排队设置"
|
||||
super.viewDidLoad()
|
||||
wireViewModel(viewModel) { }
|
||||
}
|
||||
|
||||
override func tableRowCount() -> Int { viewModel.scenicSpots.count }
|
||||
|
||||
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
|
||||
let spot = viewModel.scenicSpots[indexPath.row]
|
||||
let selected = viewModel.selectedSpotId == spot.id
|
||||
cell.configure(title: spot.name, subtitle: selected ? "当前打卡点" : nil)
|
||||
cell.accessoryType = selected ? .checkmark : .none
|
||||
}
|
||||
|
||||
override func didSelectTableRow(at indexPath: IndexPath) {
|
||||
viewModel.selectedSpotId = viewModel.scenicSpots[indexPath.row].id
|
||||
}
|
||||
|
||||
override func reloadContent() async {
|
||||
await viewModel.load(
|
||||
api: services.scenicQueueAPI,
|
||||
scenicId: services.currentScenicId,
|
||||
userId: services.userId,
|
||||
spots: services.scenicSpotContext.spots
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicQueueSettingsViewModel: ViewModelBindable {}
|
||||
@ -0,0 +1,882 @@
|
||||
//
|
||||
// QueueManagementViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
/// 排队管理列表 ViewModel,负责打卡点门禁、列表分页、动作和实时事件。
|
||||
final class QueueManagementViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var loadingMore = false { didSet { onChange?() } }
|
||||
var items: [QueueItem] = [] { didSet { onChange?() } }
|
||||
var scenicSpots: [ScenicSpotItem] = [] { didSet { onChange?() } }
|
||||
var selectedSpotId: Int? { didSet { onChange?() } }
|
||||
var selectedListType: QueueListType = .queueing { didSet { onChange?() } }
|
||||
var queueCount = 0 { didSet { onChange?() } }
|
||||
var avgWaitMin = 0.0 { didSet { onChange?() } }
|
||||
var totalCount = 0 { didSet { onChange?() } }
|
||||
var hasMore = false { didSet { onChange?() } }
|
||||
var page = 1 { didSet { onChange?() } }
|
||||
var lastSyncTimeText = "--" { didSet { onChange?() } }
|
||||
var queueGatePassed = false { didSet { onChange?() } }
|
||||
var selectedSpotName = "--" { didSet { onChange?() } }
|
||||
var showStartShootingButton = true { didSet { onChange?() } }
|
||||
var autoCallAheadCount = 0 { didSet { onChange?() } }
|
||||
var quickCallButtonEnabled = false { didSet { onChange?() } }
|
||||
var prepareCallButtonEnabled = false { didSet { onChange?() } }
|
||||
var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement? { didSet { onChange?() } }
|
||||
var errorMessage: String? { didSet { onChange?() } }
|
||||
|
||||
private let pageSize = 10
|
||||
private let socketClient = ScenicQueueSocketClient()
|
||||
private var realtimeSpotId: Int?
|
||||
private var realtimeScenicId: Int?
|
||||
private var currentUserId: String?
|
||||
private var currentScenicId: Int?
|
||||
private var pendingRemoteCalledRecordIds = Set<Int64>()
|
||||
private var handledRemoteCallEventIds = Set<String>()
|
||||
|
||||
/// 待处理排队数量。
|
||||
var pendingCount: Int {
|
||||
items.filter { $0.status < 7 && !$0.statusText.contains("完成") }.count
|
||||
}
|
||||
|
||||
/// 平均等待时间文案。
|
||||
var avgWaitMinText: String {
|
||||
avgWaitMin.rounded() == avgWaitMin ? "\(Int(avgWaitMin))" : String(format: "%.1f", avgWaitMin)
|
||||
}
|
||||
|
||||
/// 当前打卡点名称。
|
||||
var currentSpotName: String {
|
||||
scenicSpots.first(where: { $0.id == selectedSpotId })?.name
|
||||
?? selectedSpotName.nonEmptyOrDefault(queueGatePassed ? "--" : "未设置打卡点")
|
||||
}
|
||||
|
||||
/// 重新加载排队数据。没有已保存打卡点时只展示设置引导,不自动选第一个。
|
||||
func reload(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {
|
||||
guard let scenicId else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
|
||||
currentUserId = userId
|
||||
currentScenicId = scenicId
|
||||
scenicSpots = spots
|
||||
|
||||
let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
|
||||
if let savedSpotId, spots.isEmpty || spots.contains(where: { $0.id == savedSpotId }) {
|
||||
selectedSpotId = savedSpotId
|
||||
selectedSpotName = ScenicQueueSettingsStore.selectedSpotName(userId: userId, scenicId: scenicId)
|
||||
.nonEmptyOrDefault(spots.first(where: { $0.id == savedSpotId })?.name ?? "--")
|
||||
queueGatePassed = true
|
||||
await syncQueueSettingFromServerIfMatchesLocal(api: api, userId: userId, scenicId: scenicId, spotId: savedSpotId)
|
||||
refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: savedSpotId)
|
||||
} else {
|
||||
selectedSpotId = nil
|
||||
selectedSpotName = "--"
|
||||
queueGatePassed = false
|
||||
clearQueueData()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1)
|
||||
} catch {
|
||||
clearQueueData()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换列表类型并加载第一页。
|
||||
func selectListType(_ type: QueueListType, api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
|
||||
guard selectedListType != type else { return }
|
||||
selectedListType = type
|
||||
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
|
||||
}
|
||||
|
||||
/// 重新加载当前打卡点列表。
|
||||
func reloadCurrentSpot(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
|
||||
guard let scenicId else { return }
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
defer { loading = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1)
|
||||
} catch {
|
||||
clearQueueData()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载下一页。
|
||||
func loadMore(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
|
||||
guard let scenicId, hasMore, !loadingMore else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
do {
|
||||
try await loadPage(api: api, scenicId: scenicId, userId: userId, page: page + 1)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 叫号,成功后仅本地标记为已叫号。
|
||||
func callQueue(api: any ScenicQueueServing, id: Int64) async throws {
|
||||
let data = try await api.scenicQueueCall(id: id)
|
||||
markQueueCalled(id: id, statusText: data.statusText)
|
||||
}
|
||||
|
||||
/// 标记过号并刷新列表。
|
||||
func passQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws {
|
||||
_ = try await api.scenicQueuePass(id: id)
|
||||
items.removeAll { $0.id == id }
|
||||
queueCount = max(queueCount - 1, 0)
|
||||
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
|
||||
}
|
||||
|
||||
/// 标记完成并刷新列表。
|
||||
func finishQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws -> ScenicQueueFinishData {
|
||||
let data = try await api.scenicQueueFinish(id: id)
|
||||
items.removeAll { $0.id == id }
|
||||
queueCount = max(queueCount - 1, 0)
|
||||
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
|
||||
return data
|
||||
}
|
||||
|
||||
/// 重新排队并切回当前排队列表。
|
||||
func requeue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64, operatorId: Int) async throws {
|
||||
try await api.scenicQueueRequeueInsertBefore(recordId: id, operatorId: operatorId)
|
||||
selectedListType = .queueing
|
||||
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
|
||||
}
|
||||
|
||||
/// 标记用户身份/限制排队。
|
||||
func userMark(api: any ScenicQueueServing, scenicId: Int?, userId: String?, request: ScenicQueueUserMarkRequest) async throws {
|
||||
try await api.scenicQueueUserMark(request)
|
||||
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
|
||||
}
|
||||
|
||||
/// 启动页面前台实时监听。
|
||||
func startRealtime(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
|
||||
guard queueGatePassed, let scenicId, let scenicSpotId = selectedSpotId else {
|
||||
stopRealtime()
|
||||
return
|
||||
}
|
||||
guard realtimeScenicId != scenicId || realtimeSpotId != scenicSpotId else { return }
|
||||
stopRealtime()
|
||||
do {
|
||||
let token = try await api.socketToken().socketToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !token.isEmpty else { return }
|
||||
realtimeScenicId = scenicId
|
||||
realtimeSpotId = scenicSpotId
|
||||
socketClient.connect(socketToken: token, scenicSpotId: scenicSpotId) { [weak self] message in
|
||||
Task { @MainActor in
|
||||
await self?.handleSocketMessage(message, api: api, scenicId: scenicId, userId: userId)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止页面实时监听。
|
||||
func stopRealtime() {
|
||||
socketClient.disconnect()
|
||||
realtimeScenicId = nil
|
||||
realtimeSpotId = nil
|
||||
pendingRemoteCalledRecordIds.removeAll()
|
||||
handledRemoteCallEventIds.removeAll()
|
||||
}
|
||||
|
||||
/// 当前拍摄预计时长秒数。
|
||||
var shootingDurationSeconds: Int {
|
||||
guard let scenicId = currentScenicId, let selectedSpotId,
|
||||
let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId)
|
||||
else {
|
||||
return max(UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey), 0)
|
||||
}
|
||||
return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0)
|
||||
}
|
||||
|
||||
private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
|
||||
guard message.isScenicQueueEvent,
|
||||
let params = message.data?.params,
|
||||
let changedSpotId = params.scenicSpotId,
|
||||
let selectedSpotId,
|
||||
changedSpotId == Int64(selectedSpotId)
|
||||
else { return }
|
||||
|
||||
switch message.data?.action {
|
||||
case ScenicQueueSocketMessage.queueUpdatedAction:
|
||||
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
|
||||
case ScenicQueueSocketMessage.ticketCalledAction:
|
||||
await handleRemoteTicketCalled(params: params, api: api, scenicId: scenicId, userId: userId)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRemoteTicketCalled(params: ScenicQueueSocketParams, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
|
||||
guard let recordId = params.recordId, recordId > 0 else { return }
|
||||
if let operatorUid = params.operatorUid,
|
||||
let myUid = Int64(userId ?? ""),
|
||||
operatorUid == myUid {
|
||||
return
|
||||
}
|
||||
let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !eventId.isEmpty, !handledRemoteCallEventIds.insert(eventId).inserted {
|
||||
return
|
||||
}
|
||||
pendingRemoteCalledRecordIds.insert(recordId)
|
||||
guard let selectedSpotId else { return }
|
||||
_ = try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: selectedSpotId)
|
||||
let tickets = (try? await loadQueueingTickets(api: api, scenicId: scenicId, scenicSpotId: selectedSpotId)) ?? []
|
||||
if selectedListType == .queueing {
|
||||
items = tickets
|
||||
totalCount = max(totalCount, tickets.count)
|
||||
hasMore = tickets.count < totalCount
|
||||
}
|
||||
consumePendingRemoteCalledTickets(from: tickets)
|
||||
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
|
||||
}
|
||||
|
||||
private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] {
|
||||
let home = try await api.scenicQueueHome(
|
||||
scenicId: scenicId,
|
||||
scenicSpotId: scenicSpotId,
|
||||
type: QueueListType.queueing.rawValue,
|
||||
page: 1,
|
||||
pageSize: pageSize
|
||||
)
|
||||
return home.list?.list ?? []
|
||||
}
|
||||
|
||||
private func consumePendingRemoteCalledTickets(from tickets: [QueueItem]) {
|
||||
guard let ticket = tickets.first(where: {
|
||||
pendingRemoteCalledRecordIds.contains($0.id) && ($0.isCalled == 1 || $0.status == 1 || $0.statusText.contains("已叫号"))
|
||||
}) else {
|
||||
return
|
||||
}
|
||||
pendingRemoteCalledRecordIds.remove(ticket.id)
|
||||
remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode)
|
||||
}
|
||||
|
||||
private func loadPage(api: any ScenicQueueServing, scenicId: Int, userId: String?, page targetPage: Int) async throws {
|
||||
guard let scenicSpotId = selectedSpotId else { return }
|
||||
let home = try await api.scenicQueueHome(
|
||||
scenicId: scenicId,
|
||||
scenicSpotId: scenicSpotId,
|
||||
type: selectedListType.rawValue,
|
||||
page: targetPage,
|
||||
pageSize: pageSize
|
||||
)
|
||||
let stats = (try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: scenicSpotId)) ?? ScenicQueueStatsData()
|
||||
let block = home.list
|
||||
let nextList = block?.list ?? []
|
||||
|
||||
if targetPage <= 1 {
|
||||
items = nextList
|
||||
} else {
|
||||
items.append(contentsOf: nextList)
|
||||
}
|
||||
page = targetPage
|
||||
totalCount = block?.total ?? items.count
|
||||
hasMore = page * pageSize < totalCount
|
||||
queueCount = stats.queueCount
|
||||
avgWaitMin = stats.avgWaitMin
|
||||
let syncText = stats.time.isEmpty ? (home.time ?? "") : stats.time
|
||||
lastSyncTimeText = syncText.isEmpty ? "--" : syncText
|
||||
refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId)
|
||||
}
|
||||
|
||||
private func markQueueCalled(id: Int64, statusText: String) {
|
||||
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
items = items.map { item in
|
||||
guard item.id == id else { return item }
|
||||
return ScenicQueueTicket(
|
||||
id: item.id,
|
||||
queueCode: item.queueCode,
|
||||
mobile: item.mobile,
|
||||
status: max(item.status, 1),
|
||||
statusText: trimmedStatus.nonEmptyOrDefault(item.statusText.contains("已叫号") ? item.statusText : "已叫号"),
|
||||
waitMin: item.waitMin,
|
||||
aheadCount: item.aheadCount,
|
||||
isCalled: 1,
|
||||
createdAt: item.createdAt,
|
||||
calledAt: item.calledAt,
|
||||
expiredAt: item.expiredAt,
|
||||
finishedAt: item.finishedAt,
|
||||
queueBanLabel: item.queueBanLabel,
|
||||
identityTag: item.identityTag,
|
||||
queueTime: item.queueTime,
|
||||
queueCountToday: item.queueCountToday,
|
||||
uid: item.uid,
|
||||
markAsPhotog: item.markAsPhotog,
|
||||
markAsFreelancePhotog: item.markAsFreelancePhotog,
|
||||
isMissRequeue: item.isMissRequeue,
|
||||
missRequeueText: item.missRequeueText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
stopRealtime()
|
||||
loading = false
|
||||
loadingMore = false
|
||||
scenicSpots = []
|
||||
selectedSpotId = nil
|
||||
selectedSpotName = "--"
|
||||
queueGatePassed = false
|
||||
selectedListType = .queueing
|
||||
currentScenicId = nil
|
||||
clearQueueData()
|
||||
}
|
||||
|
||||
private func clearQueueData() {
|
||||
loadingMore = false
|
||||
items = []
|
||||
queueCount = 0
|
||||
avgWaitMin = 0
|
||||
totalCount = 0
|
||||
hasMore = false
|
||||
page = 1
|
||||
lastSyncTimeText = "--"
|
||||
}
|
||||
|
||||
private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) {
|
||||
let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot()
|
||||
showStartShootingButton = snapshot.showStartShootingButton
|
||||
autoCallAheadCount = min(max(snapshot.autoCallAheadCount, 0), 5)
|
||||
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
|
||||
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
|
||||
}
|
||||
|
||||
private func syncQueueSettingFromServerIfMatchesLocal(api: any ScenicQueueServing, userId: String?, scenicId: Int, spotId: Int) async {
|
||||
guard let data = try? await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: spotId),
|
||||
data.exists,
|
||||
let setting = data.setting
|
||||
else { return }
|
||||
if let settingScenicId = setting.scenicId, settingScenicId != scenicId { return }
|
||||
if let settingSpotId = setting.scenicSpotId, settingSpotId != spotId { return }
|
||||
|
||||
let local = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot()
|
||||
let snapshot = ScenicQueueSettingsSnapshot(
|
||||
shootMinute: min(max(setting.photoEstimateMin, 0), 30),
|
||||
shootSecond: min(max(setting.photoEstimateSec, 0), 59),
|
||||
firstAheadCount: min(max(setting.firstNoticeThresholdPos, 0), 50),
|
||||
firstSms: setting.firstNoticeSmsEnabled == 1,
|
||||
firstPhone: setting.firstNoticeCallEnabled == 1,
|
||||
secondAheadCount: min(max(setting.secondNoticeThresholdPos, 0), 50),
|
||||
secondSms: setting.secondNoticeSmsEnabled == 1,
|
||||
secondPhone: setting.secondNoticeCallEnabled == 1,
|
||||
broadcastIntervalSec: ScenicQueueSettingsViewModel.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec),
|
||||
countdownThresholdSec: ScenicQueueSettingsViewModel.coercedCountdownThreshold(setting.countdownReadableThresholdSec),
|
||||
queueDistanceMeter: min(max(setting.queueDistanceMeter, 0), 9_999_990),
|
||||
queueTakeLimit: min(max(setting.queueTakeLimit, 0), 999_999),
|
||||
missCallRequeueOffset: min(max(setting.missCallRequeueOffset, 1), 9_999),
|
||||
showStartShootingButton: (setting.showStartShootButton ?? (local.showStartShootingButton ? 1 : 0)) == 1,
|
||||
autoCallAheadCount: min(max(setting.autoCallNextCount ?? local.autoCallAheadCount, 0), 5),
|
||||
quickCallButtonEnabled: local.quickCallButtonEnabled,
|
||||
prepareCallButtonEnabled: local.prepareCallButtonEnabled,
|
||||
businessOpen: setting.status == 1,
|
||||
businessStartTime: setting.businessStartTime,
|
||||
businessEndTime: setting.businessEndTime
|
||||
)
|
||||
ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: userId, scenicId: scenicId, spotId: spotId)
|
||||
ScenicQueueSettingsStore.saveSelectedSpot(
|
||||
id: spotId,
|
||||
name: setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName),
|
||||
userId: userId,
|
||||
scenicId: scenicId
|
||||
)
|
||||
if !setting.remark.isEmpty, setting.remark != "A0001" {
|
||||
ScenicQueueSettingsStore.saveCustomTtsText(setting.remark, userId: userId, scenicId: scenicId, spotId: spotId)
|
||||
}
|
||||
let voices = setting.voiceBroadcasts
|
||||
.sorted { $0.sortOrder < $1.sortOrder }
|
||||
.map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if !voices.isEmpty {
|
||||
ScenicQueueSettingsStore.savePresetVoices(Array(voices.prefix(5)), userId: userId, scenicId: scenicId, spotId: spotId)
|
||||
}
|
||||
selectedSpotName = setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 排队设置 ViewModel,负责设置读取、校验、保存、二维码和日志。
|
||||
final class ScenicQueueSettingsViewModel {
|
||||
var onChange: (() -> Void)?
|
||||
var loading = false { didSet { onChange?() } }
|
||||
var message: String? { didSet { onChange?() } }
|
||||
var scenicSpots: [ScenicSpotItem] = [] { didSet { onChange?() } }
|
||||
var selectedSpotId: Int? { didSet { onChange?() } }
|
||||
var photoEstimateMin = "0" { didSet { onChange?() } }
|
||||
var photoEstimateSec = "30" { didSet { onChange?() } }
|
||||
var firstAhead = "0" { didSet { onChange?() } }
|
||||
var firstSms = false { didSet { onChange?() } }
|
||||
var firstPhone = false { didSet { onChange?() } }
|
||||
var secondAhead = "0" { didSet { onChange?() } }
|
||||
var secondSms = false { didSet { onChange?() } }
|
||||
var secondPhone = false { didSet { onChange?() } }
|
||||
var broadcastIntervalSec = "50" { didSet { onChange?() } }
|
||||
var countdownThresholdSec = "15" { didSet { onChange?() } }
|
||||
var maxQueueRangeKm = "0.00" { didSet { onChange?() } }
|
||||
var localQueueLimit = "0" { didSet { onChange?() } }
|
||||
var localPassDelay = "1" { didSet { onChange?() } }
|
||||
var showStartShootingButton = true { didSet { onChange?() } }
|
||||
var autoCallAheadCount = "0" { didSet { onChange?() } }
|
||||
var queueOpen = true { didSet { onChange?() } }
|
||||
var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0) { didSet { onChange?() } }
|
||||
var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0) { didSet { onChange?() } }
|
||||
var customTtsText = "" { didSet { onChange?() } }
|
||||
var presetVoices: [String] = [] { didSet { onChange?() } }
|
||||
var quickCallButtonEnabled = false { didSet { onChange?() } }
|
||||
var prepareCallButtonEnabled = false { didSet { onChange?() } }
|
||||
var logs: [ScenicQueueSettingChangeLogItem] = [] { didSet { onChange?() } }
|
||||
var qrcodeURL = "" { didSet { onChange?() } }
|
||||
|
||||
private let speaker = ScenicQueueSpeechService()
|
||||
private var currentUserId: String?
|
||||
private var currentScenicId: Int?
|
||||
|
||||
/// 加载设置。设置页允许自动选中第一个打卡点。
|
||||
func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {
|
||||
guard let scenicId else {
|
||||
resetSettingsState()
|
||||
message = "请先选择景区"
|
||||
return
|
||||
}
|
||||
currentUserId = userId
|
||||
currentScenicId = scenicId
|
||||
scenicSpots = spots
|
||||
message = nil
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
logs = []
|
||||
|
||||
let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
|
||||
selectedSpotId = savedSpotId.flatMap { id in spots.first(where: { $0.id == id })?.id ?? id } ?? selectedSpotId ?? spots.first?.id
|
||||
guard selectedSpotId != nil else { return }
|
||||
await loadSelectedSpotSetting(api: api, scenicId: scenicId, userId: userId)
|
||||
}
|
||||
|
||||
/// 加载当前选中打卡点设置。
|
||||
func loadSelectedSpotSetting(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
|
||||
guard let scenicId, let selectedSpotId else { return }
|
||||
currentUserId = userId
|
||||
currentScenicId = scenicId
|
||||
presetVoices = ScenicQueueSettingsStore.presetVoices(userId: userId, scenicId: scenicId, spotId: selectedSpotId)
|
||||
let savedCustomText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: selectedSpotId)
|
||||
if !savedCustomText.isEmpty, savedCustomText != "A0001" {
|
||||
customTtsText = savedCustomText
|
||||
}
|
||||
if let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: selectedSpotId) {
|
||||
apply(snapshot)
|
||||
}
|
||||
do {
|
||||
let setting = try await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: selectedSpotId)
|
||||
if setting.exists, let item = setting.setting {
|
||||
apply(item)
|
||||
ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot(), userId: userId, scenicId: scenicId, spotId: selectedSpotId)
|
||||
}
|
||||
} catch {
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载设置变更日志。
|
||||
func loadSettingChangeLogs(api: any ScenicQueueServing, scenicId: Int?) async {
|
||||
guard let scenicId else {
|
||||
message = "请先选择景区"
|
||||
return
|
||||
}
|
||||
do {
|
||||
let data = try await api.scenicQueueSettingChangeLog(scenicId: scenicId, scenicSpotId: selectedSpotId, page: 1, pageSize: 20)
|
||||
logs = data.list
|
||||
} catch {
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取排队二维码 URL。
|
||||
func fetchQRCode(api: any ScenicQueueServing, scenicId: Int?) async throws -> String {
|
||||
guard let scenicId else { throw APIError.networkFailed("请先选择景区") }
|
||||
guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") }
|
||||
let data = try await api.scenicQueueShootQueueQRCode(scenicId: scenicId, scenicSpotId: selectedSpotId)
|
||||
let url = data.qrcodeUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !url.isEmpty else { throw APIError.networkFailed("服务端未返回二维码图片地址") }
|
||||
qrcodeURL = url
|
||||
return url
|
||||
}
|
||||
|
||||
/// 保存二维码图片到系统相册。
|
||||
func saveQRCodeToPhotoLibrary(qrcodeURL: String) async throws {
|
||||
let trimmed = qrcodeURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw APIError.networkFailed("服务端未返回二维码图片地址")
|
||||
}
|
||||
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||
guard status == .authorized || status == .limited else {
|
||||
throw APIError.networkFailed("请在系统设置中允许保存图片到相册")
|
||||
}
|
||||
let image = try await loadQRCodeImage(from: trimmed)
|
||||
try await PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetChangeRequest.creationRequestForAsset(from: image)
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存排队设置。
|
||||
func save(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async throws {
|
||||
guard let scenicId else { throw APIError.networkFailed("请先选择景区") }
|
||||
guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") }
|
||||
currentUserId = userId
|
||||
currentScenicId = scenicId
|
||||
let min = try AppFormValidator.rangedInteger(photoEstimateMin, name: "拍摄时长分钟", range: 0...30)
|
||||
let sec = try AppFormValidator.rangedInteger(photoEstimateSec, name: "拍摄时长秒", range: 0...59)
|
||||
guard min > 0 || sec > 0 else { throw APIError.networkFailed("拍摄时长须大于 0") }
|
||||
let first = try AppFormValidator.rangedInteger(firstAhead, name: "第一次通知阈值", range: 0...50)
|
||||
let second = try AppFormValidator.rangedInteger(secondAhead, name: "第二次通知阈值", range: 0...50)
|
||||
try AppFormValidator.validateQueueNoticeThresholds(first: first, second: second)
|
||||
let interval = try AppFormValidator.rangedInteger(broadcastIntervalSec, name: "播报间隔时间", range: 40...60)
|
||||
let countdown = try AppFormValidator.rangedInteger(countdownThresholdSec, name: "进入读秒倒计时阈值", range: 10...30)
|
||||
let autoCallAhead = try AppFormValidator.rangedInteger(autoCallAheadCount, name: "自动叫号后续几位", range: 0...5)
|
||||
maxQueueRangeKm = try normalizedMaxQueueRangeForDisplay()
|
||||
let queueDistanceMeter = try queueDistanceMeterForSubmit()
|
||||
let queueTakeLimit = try AppFormValidator.rangedInteger(localQueueLimit, name: "排队次数限制", range: 0...999_999)
|
||||
let missCallRequeueOffset = try AppFormValidator.rangedInteger(localPassDelay, name: "过号顺延", range: 1...9_999)
|
||||
guard customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).count <= 255 else {
|
||||
throw APIError.networkFailed("自定义播报文本最多 255 个字")
|
||||
}
|
||||
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
try await api.scenicQueueSaveSetting(ScenicQueueSaveSettingRequest(
|
||||
scenicId: scenicId,
|
||||
scenicSpotId: selectedSpotId,
|
||||
photoEstimateMin: min,
|
||||
photoEstimateSec: sec,
|
||||
firstNoticeThresholdPos: first,
|
||||
firstNoticeSmsEnabled: firstSms ? 1 : 0,
|
||||
firstNoticeCallEnabled: firstPhone ? 1 : 0,
|
||||
secondNoticeThresholdPos: second,
|
||||
secondNoticeSmsEnabled: secondSms ? 1 : 0,
|
||||
secondNoticeCallEnabled: secondPhone ? 1 : 0,
|
||||
countdownBroadcastIntervalSec: interval,
|
||||
countdownReadableThresholdSec: countdown,
|
||||
queueDistanceMeter: queueDistanceMeter,
|
||||
queueTakeLimit: queueTakeLimit,
|
||||
missCallRequeueOffset: missCallRequeueOffset,
|
||||
showStartShootButton: showStartShootingButton ? 1 : 0,
|
||||
autoCallNextCount: autoCallAhead,
|
||||
businessStartTime: Self.businessTimePayload(businessStartTime),
|
||||
businessEndTime: Self.businessTimePayload(businessEndTime),
|
||||
voiceBroadcasts: voiceBroadcastsForSavePayload(),
|
||||
status: queueOpen ? 1 : 0,
|
||||
remark: customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||
))
|
||||
let spotName = scenicSpots.first(where: { $0.id == selectedSpotId })?.name ?? ""
|
||||
ScenicQueueSettingsStore.saveSelectedSpot(id: selectedSpotId, name: spotName, userId: userId, scenicId: scenicId)
|
||||
saveTimingSnapshot(min: min, sec: sec, interval: interval, countdown: countdown)
|
||||
saveShootingCallSettings(autoCallAhead: autoCallAhead)
|
||||
saveCustomTextLocally()
|
||||
ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: userId, scenicId: scenicId, spotId: selectedSpotId)
|
||||
ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot(
|
||||
min: min,
|
||||
sec: sec,
|
||||
first: first,
|
||||
second: second,
|
||||
interval: interval,
|
||||
countdown: countdown,
|
||||
queueDistanceMeter: queueDistanceMeter,
|
||||
queueTakeLimit: queueTakeLimit,
|
||||
missCallRequeueOffset: missCallRequeueOffset,
|
||||
autoCallAhead: autoCallAhead
|
||||
), userId: userId, scenicId: scenicId, spotId: selectedSpotId)
|
||||
}
|
||||
|
||||
/// 保存自定义文本为本地预设。
|
||||
func saveCustomTextAsPreset() {
|
||||
let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard text.count <= 255 else {
|
||||
message = "自定义播报文本最多 255 个字"
|
||||
return
|
||||
}
|
||||
saveCustomTextLocally()
|
||||
if !text.isEmpty, !presetVoices.contains(text), presetVoices.count < 5 {
|
||||
presetVoices.append(text)
|
||||
}
|
||||
if let scenicId = currentScenicId, let selectedSpotId {
|
||||
ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新最大排队范围输入。
|
||||
func updateMaxQueueRange(_ value: String) {
|
||||
maxQueueRangeKm = AppFormValidator.sanitizedMoneyInput(value, maxDecimalPlaces: 2)
|
||||
}
|
||||
|
||||
/// 播放测试音。
|
||||
func playTestSound() {
|
||||
speaker.speak("蓝牙音响测试音,请确认外放音量是否合适。")
|
||||
}
|
||||
|
||||
/// 播放自定义播报文本。
|
||||
func playCustomText() {
|
||||
let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else {
|
||||
message = "请输入要播报的文字"
|
||||
return
|
||||
}
|
||||
speaker.speak(text)
|
||||
}
|
||||
|
||||
/// 删除预设语音。
|
||||
func deletePresetVoice(at index: Int) {
|
||||
guard presetVoices.indices.contains(index) else { return }
|
||||
presetVoices.remove(at: index)
|
||||
}
|
||||
|
||||
private func resetSettingsState() {
|
||||
loading = false
|
||||
scenicSpots = []
|
||||
selectedSpotId = nil
|
||||
photoEstimateMin = "0"
|
||||
photoEstimateSec = "30"
|
||||
firstAhead = "0"
|
||||
firstSms = false
|
||||
firstPhone = false
|
||||
secondAhead = "0"
|
||||
secondSms = false
|
||||
secondPhone = false
|
||||
broadcastIntervalSec = "50"
|
||||
countdownThresholdSec = "15"
|
||||
maxQueueRangeKm = "0.00"
|
||||
localQueueLimit = "0"
|
||||
localPassDelay = "1"
|
||||
showStartShootingButton = true
|
||||
autoCallAheadCount = "0"
|
||||
queueOpen = true
|
||||
businessStartTime = Self.businessTime(hour: 10, minute: 0)
|
||||
businessEndTime = Self.businessTime(hour: 20, minute: 0)
|
||||
customTtsText = ""
|
||||
logs = []
|
||||
presetVoices = []
|
||||
quickCallButtonEnabled = false
|
||||
prepareCallButtonEnabled = false
|
||||
qrcodeURL = ""
|
||||
}
|
||||
|
||||
private func apply(_ setting: ScenicQueueSettingItem?) {
|
||||
guard let setting else { return }
|
||||
if let spotId = setting.scenicSpotId { selectedSpotId = spotId }
|
||||
photoEstimateMin = "\(setting.photoEstimateMin)"
|
||||
photoEstimateSec = "\(setting.photoEstimateSec)"
|
||||
firstAhead = "\(setting.firstNoticeThresholdPos)"
|
||||
firstSms = setting.firstNoticeSmsEnabled == 1
|
||||
firstPhone = setting.firstNoticeCallEnabled == 1
|
||||
secondAhead = "\(setting.secondNoticeThresholdPos)"
|
||||
secondSms = setting.secondNoticeSmsEnabled == 1
|
||||
secondPhone = setting.secondNoticeCallEnabled == 1
|
||||
broadcastIntervalSec = "\(Self.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec))"
|
||||
countdownThresholdSec = "\(Self.coercedCountdownThreshold(setting.countdownReadableThresholdSec))"
|
||||
maxQueueRangeKm = Self.queueDistanceDisplay(meters: setting.queueDistanceMeter)
|
||||
localQueueLimit = "\(min(max(setting.queueTakeLimit, 0), 999_999))"
|
||||
localPassDelay = "\(min(max(setting.missCallRequeueOffset, 1), 9_999))"
|
||||
if let showStartShootButton = setting.showStartShootButton { showStartShootingButton = showStartShootButton == 1 }
|
||||
if let autoCallNextCount = setting.autoCallNextCount { autoCallAheadCount = "\(min(max(autoCallNextCount, 0), 5))" }
|
||||
queueOpen = setting.status == 1
|
||||
businessStartTime = Self.parseBusinessTime(setting.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0))
|
||||
businessEndTime = Self.parseBusinessTime(setting.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0))
|
||||
if !setting.remark.isEmpty, setting.remark != "A0001" { customTtsText = setting.remark }
|
||||
let voices = setting.voiceBroadcasts
|
||||
.sorted { $0.sortOrder < $1.sortOrder }
|
||||
.map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) }
|
||||
}
|
||||
|
||||
private func apply(_ snapshot: ScenicQueueSettingsSnapshot) {
|
||||
photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))"
|
||||
photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))"
|
||||
firstAhead = "\(min(max(snapshot.firstAheadCount, 0), 50))"
|
||||
firstSms = snapshot.firstSms
|
||||
firstPhone = snapshot.firstPhone
|
||||
secondAhead = "\(min(max(snapshot.secondAheadCount, 0), 50))"
|
||||
secondSms = snapshot.secondSms
|
||||
secondPhone = snapshot.secondPhone
|
||||
broadcastIntervalSec = "\(Self.coercedBroadcastInterval(snapshot.broadcastIntervalSec))"
|
||||
countdownThresholdSec = "\(Self.coercedCountdownThreshold(snapshot.countdownThresholdSec))"
|
||||
maxQueueRangeKm = Self.queueDistanceDisplay(meters: snapshot.queueDistanceMeter)
|
||||
localQueueLimit = "\(min(max(snapshot.queueTakeLimit, 0), 999_999))"
|
||||
localPassDelay = "\(min(max(snapshot.missCallRequeueOffset, 1), 9_999))"
|
||||
showStartShootingButton = snapshot.showStartShootingButton
|
||||
autoCallAheadCount = "\(min(max(snapshot.autoCallAheadCount, 0), 5))"
|
||||
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
|
||||
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
|
||||
queueOpen = snapshot.businessOpen
|
||||
businessStartTime = Self.parseBusinessTime(snapshot.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0))
|
||||
businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0))
|
||||
}
|
||||
|
||||
private func normalizedMaxQueueRangeForDisplay() throws -> String {
|
||||
let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm)
|
||||
guard !normalized.isEmpty,
|
||||
normalized.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil,
|
||||
let value = Decimal(string: normalized, locale: Locale(identifier: "en_US_POSIX"))
|
||||
else {
|
||||
throw APIError.networkFailed("允许排队的最大范围格式不正确")
|
||||
}
|
||||
let max = Decimal(999_999) / Decimal(100)
|
||||
guard value >= 0, value <= max else {
|
||||
throw APIError.networkFailed("允许排队的最大范围须在 0~9999.99")
|
||||
}
|
||||
return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue)
|
||||
}
|
||||
|
||||
private func queueDistanceMeterForSubmit() throws -> Int {
|
||||
_ = try normalizedMaxQueueRangeForDisplay()
|
||||
let value = Decimal(string: maxQueueRangeKm, locale: Locale(identifier: "en_US_POSIX")) ?? 0
|
||||
return NSDecimalNumber(decimal: value * Decimal(1000)).intValue
|
||||
}
|
||||
|
||||
private func loadQRCodeImage(from text: String) async throws -> UIImage {
|
||||
if let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased()) {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
if let image = UIImage(data: data) { return image }
|
||||
}
|
||||
if let generated = Self.generateQRCode(from: text) { return generated }
|
||||
throw APIError.networkFailed("二维码图片生成失败")
|
||||
}
|
||||
|
||||
private static func generateQRCode(from string: String) -> UIImage? {
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(string.utf8)
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: 8, y: 8))
|
||||
let context = CIContext()
|
||||
guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil }
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
|
||||
private func saveTimingSnapshot(min: Int, sec: Int, interval: Int, countdown: Int) {
|
||||
UserDefaults.standard.set(max(min * 60 + sec, 0), forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey)
|
||||
UserDefaults.standard.set(interval, forKey: ScenicQueueLocalSettings.broadcastIntervalSecondsKey)
|
||||
UserDefaults.standard.set(countdown, forKey: ScenicQueueLocalSettings.countdownThresholdSecondsKey)
|
||||
}
|
||||
|
||||
private func saveShootingCallSettings(autoCallAhead: Int) {
|
||||
UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey)
|
||||
UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey)
|
||||
UserDefaults.standard.set(quickCallButtonEnabled, forKey: ScenicQueueLocalSettings.quickCallButtonEnabledKey)
|
||||
UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey)
|
||||
}
|
||||
|
||||
private func saveCustomTextLocally() {
|
||||
ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId)
|
||||
}
|
||||
|
||||
private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] {
|
||||
Array(presetVoices
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.prefix(5)
|
||||
.enumerated()
|
||||
.map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) })
|
||||
}
|
||||
|
||||
private func makeSnapshot(
|
||||
min: Int? = nil,
|
||||
sec: Int? = nil,
|
||||
first: Int? = nil,
|
||||
second: Int? = nil,
|
||||
interval: Int? = nil,
|
||||
countdown: Int? = nil,
|
||||
queueDistanceMeter: Int? = nil,
|
||||
queueTakeLimit: Int? = nil,
|
||||
missCallRequeueOffset: Int? = nil,
|
||||
autoCallAhead: Int? = nil
|
||||
) -> ScenicQueueSettingsSnapshot {
|
||||
ScenicQueueSettingsSnapshot(
|
||||
shootMinute: min ?? (Int(photoEstimateMin) ?? 0),
|
||||
shootSecond: sec ?? (Int(photoEstimateSec) ?? 30),
|
||||
firstAheadCount: first ?? (Int(firstAhead) ?? 0),
|
||||
firstSms: firstSms,
|
||||
firstPhone: firstPhone,
|
||||
secondAheadCount: second ?? (Int(secondAhead) ?? 0),
|
||||
secondSms: secondSms,
|
||||
secondPhone: secondPhone,
|
||||
broadcastIntervalSec: interval ?? (Int(broadcastIntervalSec) ?? 50),
|
||||
countdownThresholdSec: countdown ?? (Int(countdownThresholdSec) ?? 15),
|
||||
queueDistanceMeter: queueDistanceMeter ?? ((Decimal(string: maxQueueRangeKm) ?? 0) as NSDecimalNumber).intValue * 1000,
|
||||
queueTakeLimit: queueTakeLimit ?? (Int(localQueueLimit) ?? 0),
|
||||
missCallRequeueOffset: missCallRequeueOffset ?? (Int(localPassDelay) ?? 1),
|
||||
showStartShootingButton: showStartShootingButton,
|
||||
autoCallAheadCount: autoCallAhead ?? (Int(autoCallAheadCount) ?? 0),
|
||||
quickCallButtonEnabled: quickCallButtonEnabled,
|
||||
prepareCallButtonEnabled: prepareCallButtonEnabled,
|
||||
businessOpen: queueOpen,
|
||||
businessStartTime: Self.businessTimePayload(businessStartTime),
|
||||
businessEndTime: Self.businessTimePayload(businessEndTime)
|
||||
)
|
||||
}
|
||||
|
||||
static func coercedBroadcastInterval(_ raw: Int) -> Int {
|
||||
(40...60).contains(raw) ? raw : 50
|
||||
}
|
||||
|
||||
static func coercedCountdownThreshold(_ raw: Int) -> Int {
|
||||
(10...30).contains(raw) ? raw : 15
|
||||
}
|
||||
|
||||
static func businessTime(hour: Int, minute: Int) -> Date {
|
||||
Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date()
|
||||
}
|
||||
|
||||
static func parseBusinessTime(_ raw: String, fallback: Date) -> Date {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return fallback }
|
||||
for format in ["HH:mm", "HH:mm:ss"] {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: text) { return date }
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
static func businessTimePayload(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private static func queueDistanceDisplay(meters: Int) -> String {
|
||||
let clamped = min(max(meters, 0), 9_999_990)
|
||||
return String(format: "%.2f", Double(clamped) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
func nonEmptyOrDefault(_ fallback: String) -> String {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? fallback : text
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user