新增订单长尾流程,并迁移排队、消息、结算与审核模块

将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent 311a70d610
commit c39c3d3c75
63 changed files with 9823 additions and 58 deletions

View File

@ -0,0 +1,201 @@
//
// ScenicQueueAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import Observation
/// 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
@Observable
/// API `/api/app/scenic-queue`
final class ScenicQueueAPI {
@ObservationIgnored 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 {}

View File

@ -0,0 +1,491 @@
//
// ScenicQueueModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
struct ScenicQueueStatsData: Decodable, Equatable {
let queueCount: Int
let avgWaitMin: Double
let time: String
enum CodingKeys: String, CodingKey {
case queueCount = "queue_count"
case avgWaitMin = "avg_wait_min"
case time
}
///
init(queueCount: Int = 0, avgWaitMin: Double = 0, time: String = "") {
self.queueCount = queueCount
self.avgWaitMin = avgWaitMin
self.time = time
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
queueCount = try container.decodeLossyInt(forKey: .queueCount) ?? 0
avgWaitMin = try container.decodeLossyDouble(forKey: .avgWaitMin) ?? 0
time = try container.decodeLossyString(forKey: .time)
}
}
/// list
struct ScenicQueueHomeData: Decodable, Equatable {
let stats: ScenicQueueHomeStats?
let list: ScenicQueueHomeListBlock?
let time: String?
enum CodingKeys: String, CodingKey {
case stats
case list
case time
}
///
init(stats: ScenicQueueHomeStats? = nil, list: ScenicQueueHomeListBlock? = nil, time: String? = nil) {
self.stats = stats
self.list = list
self.time = time
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
stats = try container.decodeIfPresent(ScenicQueueHomeStats.self, forKey: .stats)
if let block = try? container.decodeIfPresent(ScenicQueueHomeListBlock.self, forKey: .list) {
list = block
} else if let tickets = try? container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) {
list = ScenicQueueHomeListBlock(list: tickets)
} else {
list = nil
}
time = try container.decodeIfPresent(String.self, forKey: .time)
}
}
///
struct ScenicQueueHomeStats: Decodable, Equatable {
let type: Int
enum CodingKeys: String, CodingKey {
case type
}
///
init(type: Int = 0) {
self.type = type
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decodeLossyInt(forKey: .type) ?? 0
}
}
///
struct ScenicQueueHomeListBlock: Decodable, Equatable {
let list: [ScenicQueueTicket]
let total: Int
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case list
case total
case page
case pageSize = "page_size"
}
///
init(list: [ScenicQueueTicket] = [], total: Int? = nil, page: Int = 1, pageSize: Int = 20) {
self.list = list
self.total = total ?? list.count
self.page = page
self.pageSize = pageSize
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
list = try container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) ?? []
total = try container.decodeLossyInt(forKey: .total) ?? list.count
page = try container.decodeLossyInt(forKey: .page) ?? 1
pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? max(list.count, 20)
}
}
///
struct ScenicQueueTicket: Decodable, Equatable, Identifiable {
let id: Int64
let queueCode: String
let mobile: String
let status: Int
let statusText: String
let waitMin: Int
let aheadCount: Int
let isCalled: Int
let createdAt: String
let calledAt: String
let expiredAt: String
let finishedAt: String
let queueBanLabel: String
let identityTag: String
let queueTime: String
let queueCountToday: Int
let uid: Int64
let markAsPhotog: Int
let markAsFreelancePhotog: Int
let isMissRequeue: Int
let missRequeueText: String
var phoneMasked: String {
let digits = mobile.filter(\.isNumber)
if mobile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "--" }
guard digits.count >= 7 else { return mobile }
return "\(digits.prefix(3))****\(digits.suffix(4))"
}
var dialPhoneDigits: String {
let digits = mobile.filter(\.isNumber)
return digits.isEmpty ? mobile.filter { !$0.isWhitespace } : digits
}
var queueTimeDisplay: String {
Self.formatQueueTime(queueTime.nonEmptyOrDefault(createdAt))
}
var skippedTimeDisplay: String {
Self.formatQueueTime(expiredAt.nonEmptyOrDefault(calledAt.nonEmptyOrDefault(createdAt)))
}
var shouldShowIdentityTag: Bool {
let text = identityTag.trimmingCharacters(in: .whitespacesAndNewlines)
return !text.isEmpty && text != "普通用户"
}
var isMissRequeueRecord: Bool {
isMissRequeue == 1
}
enum CodingKeys: String, CodingKey {
case id
case queueCode = "queue_code"
case mobile
case status
case statusText = "status_text"
case waitMin = "wait_min"
case aheadCount = "ahead_count"
case isCalled = "is_called"
case createdAt = "created_at"
case calledAt = "called_at"
case expiredAt = "expired_at"
case finishedAt = "finished_at"
case queueBanLabel = "queue_ban_label"
case identityTag = "identity_tag"
case queueTime = "queue_time"
case queueCountToday = "queue_count_today"
case uid
case markAsPhotog = "mark_as_photog"
case markAsFreelancePhotog = "mark_as_freelance_photog"
case isMissRequeue = "is_miss_requeue"
case missRequeueText = "is_miss_requeue_text"
}
enum AlternateCodingKeys: String, CodingKey {
case queueNo = "queue_no"
case queueNumber = "queue_number"
case code
case phone
case statusName = "status_name"
}
///
init(
id: Int64,
queueCode: String,
mobile: String,
status: Int,
statusText: String,
waitMin: Int,
aheadCount: Int,
isCalled: Int,
createdAt: String,
calledAt: String,
expiredAt: String,
finishedAt: String,
queueBanLabel: String = "",
identityTag: String = "",
queueTime: String = "",
queueCountToday: Int = 0,
uid: Int64 = 0,
markAsPhotog: Int = 0,
markAsFreelancePhotog: Int = 0,
isMissRequeue: Int = 0,
missRequeueText: String = ""
) {
self.id = id
self.queueCode = queueCode
self.mobile = mobile
self.status = status
self.statusText = statusText
self.waitMin = waitMin
self.aheadCount = aheadCount
self.isCalled = isCalled
self.createdAt = createdAt
self.calledAt = calledAt
self.expiredAt = expiredAt
self.finishedAt = finishedAt
self.queueBanLabel = queueBanLabel
self.identityTag = identityTag
self.queueTime = queueTime
self.queueCountToday = queueCountToday
self.uid = uid
self.markAsPhotog = markAsPhotog
self.markAsFreelancePhotog = markAsFreelancePhotog
self.isMissRequeue = isMissRequeue
self.missRequeueText = missRequeueText
}
/// Android
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let alternate = try decoder.container(keyedBy: AlternateCodingKeys.self)
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
queueCode = try container.decodeLossyString(forKey: .queueCode)
.nonEmptyOrDefault(
try alternate.decodeLossyString(forKey: .queueNo)
.nonEmptyOrDefault(
try alternate.decodeLossyString(forKey: .queueNumber)
.nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .code))
)
)
mobile = try container.decodeLossyString(forKey: .mobile)
.nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .phone))
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusText = try container.decodeLossyString(forKey: .statusText)
.nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .statusName))
waitMin = try container.decodeLossyInt(forKey: .waitMin) ?? 0
aheadCount = try container.decodeLossyInt(forKey: .aheadCount) ?? 0
isCalled = try container.decodeLossyInt(forKey: .isCalled) ?? 0
createdAt = try container.decodeLossyString(forKey: .createdAt)
calledAt = try container.decodeLossyString(forKey: .calledAt)
expiredAt = try container.decodeLossyString(forKey: .expiredAt)
finishedAt = try container.decodeLossyString(forKey: .finishedAt)
queueBanLabel = try container.decodeLossyString(forKey: .queueBanLabel).trimmingCharacters(in: .whitespacesAndNewlines)
identityTag = try container.decodeLossyString(forKey: .identityTag).trimmingCharacters(in: .whitespacesAndNewlines)
queueTime = try container.decodeLossyString(forKey: .queueTime)
queueCountToday = max(try container.decodeLossyInt(forKey: .queueCountToday) ?? 0, 0)
uid = Int64(try container.decodeLossyInt(forKey: .uid) ?? 0)
markAsPhotog = try container.decodeLossyInt(forKey: .markAsPhotog) ?? 0
markAsFreelancePhotog = try container.decodeLossyInt(forKey: .markAsFreelancePhotog) ?? 0
isMissRequeue = try container.decodeLossyInt(forKey: .isMissRequeue) ?? 0
missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func formatQueueTime(_ raw: String) -> String {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return "--" }
if text.range(of: #"^\d{2}-\d{2}\s+\d{2}:\d{2}$"#, options: .regularExpression) != nil {
return text
}
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] {
formatter.dateFormat = format
if let date = formatter.date(from: text) {
formatter.dateFormat = "MM-dd HH:mm"
return formatter.string(from: date)
}
}
return text
}
}
///
struct ScenicQueueActionRequest: Encodable {
let id: Int64
}
/// socket token
struct SocketTokenResponse: Decodable, Equatable {
let socketToken: String
enum CodingKeys: String, CodingKey {
case socketToken = "socket_token"
}
/// socket token
init(socketToken: String = "") {
self.socketToken = socketToken
}
/// socket token
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
socketToken = try container.decodeLossyString(forKey: .socketToken)
}
}
///
struct ScenicQueueActionData: Decodable, Equatable {
let id: Int64
let status: Int
let statusText: String
let calledAt: String?
enum CodingKeys: String, CodingKey {
case id
case status
case statusText = "status_text"
case calledAt = "called_at"
}
///
init(id: Int64 = 0, status: Int = 0, statusText: String = "", calledAt: String? = nil) {
self.id = id
self.status = status
self.statusText = statusText
self.calledAt = calledAt
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0)
status = try container.decodeLossyInt(forKey: .status) ?? 0
statusText = try container.decodeLossyString(forKey: .statusText)
calledAt = try container.decodeIfPresent(String.self, forKey: .calledAt)
}
}
typealias ScenicQueueCallData = ScenicQueueActionData
typealias ScenicQueuePassData = ScenicQueueActionData
typealias ScenicQueueFinishData = ScenicQueueActionData
typealias QueueItem = ScenicQueueTicket
///
struct ScenicQueueRequeueInsertBeforeRequest: Encodable {
let recordId: Int64
let operatorId: Int
enum CodingKeys: String, CodingKey {
case recordId = "record_id"
case operatorId = "operator_id"
}
}
///
struct ScenicQueueUserMarkRequest: Encodable, Equatable {
let uid: Int64
let scenicId: Int64
let markAsFreelancePhotog: Int
let queueBanDays: Int?
let operatorId: Int?
enum CodingKeys: String, CodingKey {
case uid
case scenicId = "scenic_id"
case markAsFreelancePhotog = "mark_as_freelance_photog"
case queueBanDays = "queue_ban_days"
case operatorId = "operator_id"
}
}
///
enum QueueListType: Int, CaseIterable, Identifiable {
case queueing = 1
case passed = 2
var id: Int { rawValue }
///
var title: String {
switch self {
case .queueing:
return "当前排队"
case .passed:
return "过号列表"
}
}
///
var emptyText: String {
switch self {
case .queueing:
return "暂无排队"
case .passed:
return "暂无过号"
}
}
}
///
struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable {
let id: Int64
let queueCode: String
}
private extension KeyedDecodingContainer {
func decodeLossyString(forKey key: Key) throws -> String {
if let value = try? decodeIfPresent(String.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return String(value)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? "1" : "0"
}
return ""
}
func decodeLossyInt(forKey key: Key) throws -> Int? {
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return Int(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key),
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
return Int(text) ?? Int(Double(text) ?? 0)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? 1 : 0
}
return nil
}
func decodeLossyDouble(forKey key: Key) throws -> Double? {
if let value = try? decodeIfPresent(Double.self, forKey: key) {
return value
}
if let value = try? decodeIfPresent(Int.self, forKey: key) {
return Double(value)
}
if let value = try? decodeIfPresent(String.self, forKey: key),
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
return Double(text)
}
return nil
}
}
private extension String {
func nonEmptyOrDefault(_ fallback: String) -> String {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? fallback : text
}
}

View File

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

View File

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

View 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` 按本地开关进行短轮询。
- 设置保存前校验拍摄时长、通知阈值、播报间隔、读秒阈值、排队范围、排队次数、过号顺延和自定义播报长度。

View File

@ -0,0 +1,884 @@
//
// QueueManagementViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import Observation
import Photos
import SwiftUI
import UIKit
@MainActor
@Observable
/// ViewModel
final class QueueManagementViewModel {
var loading = false
var loadingMore = false
var items: [QueueItem] = []
var scenicSpots: [ScenicSpotItem] = []
var selectedSpotId: Int?
var selectedListType: QueueListType = .queueing
var queueCount = 0
var avgWaitMin = 0.0
var totalCount = 0
var hasMore = false
var page = 1
var lastSyncTimeText = "--"
var queueGatePassed = false
var selectedSpotName = "--"
var showStartShootingButton = true
var autoCallAheadCount = 0
var quickCallButtonEnabled = false
var prepareCallButtonEnabled = false
var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement?
var errorMessage: String?
@ObservationIgnored private let pageSize = 10
@ObservationIgnored private let socketClient = ScenicQueueSocketClient()
@ObservationIgnored private var realtimeSpotId: Int?
@ObservationIgnored private var realtimeScenicId: Int?
@ObservationIgnored private var currentUserId: String?
@ObservationIgnored private var currentScenicId: Int?
@ObservationIgnored private var pendingRemoteCalledRecordIds = Set<Int64>()
@ObservationIgnored 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
@Observable
/// ViewModel
final class ScenicQueueSettingsViewModel {
var loading = false
var message: String?
var scenicSpots: [ScenicSpotItem] = []
var selectedSpotId: Int?
var photoEstimateMin = "0"
var photoEstimateSec = "30"
var firstAhead = "0"
var firstSms = false
var firstPhone = false
var secondAhead = "0"
var secondSms = false
var secondPhone = false
var broadcastIntervalSec = "50"
var countdownThresholdSec = "15"
var maxQueueRangeKm = "0.00"
var localQueueLimit = "0"
var localPassDelay = "1"
var showStartShootingButton = true
var autoCallAheadCount = "0"
var queueOpen = true
var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0)
var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0)
var customTtsText = ""
var presetVoices: [String] = []
var quickCallButtonEnabled = false
var prepareCallButtonEnabled = false
var logs: [ScenicQueueSettingChangeLogItem] = []
var qrcodeURL = ""
@ObservationIgnored private let speaker = ScenicQueueSpeechService()
@ObservationIgnored private var currentUserId: String?
@ObservationIgnored 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("允许排队的最大范围须在 09999.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
}
}

View File

@ -0,0 +1,464 @@
//
// QueueManagementViews.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import SwiftUI
///
struct QueueManagementView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(ScenicQueueAPI.self) private var queueAPI
@Environment(ScenicQueueRuntime.self) private var queueRuntime
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = QueueManagementViewModel()
@State private var pendingAction: QueueActionConfirmation?
@State private var markTarget: QueueItem?
@State private var processingId: Int64?
private var currentScenicId: Int? { accountContext.currentScenic?.id }
private var currentUserId: String? { accountContext.profile?.userId }
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
header
if currentScenicId == nil {
ContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文"))
.frame(minHeight: 260)
} else if !viewModel.queueGatePassed {
gateCard
} else {
listSection
}
}
.padding(AppMetrics.Spacing.pageHorizontal)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle("排队管理")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
ScenicQueueSettingsView()
} label: {
Image(systemName: "gearshape")
}
.accessibilityLabel("排队设置")
}
}
.alert(item: $pendingAction) { action in
Alert(
title: Text(action.title),
message: Text(action.message),
primaryButton: .default(Text("确认")) {
Task { await perform(action) }
},
secondaryButton: .cancel()
)
}
.sheet(item: $markTarget) { item in
QueueUserMarkView(item: item) { markAsFreelance, banDays in
await userMark(item: item, markAsFreelance: markAsFreelance, banDays: banDays)
}
}
.task(id: reloadTaskID) {
await reload()
await viewModel.startRealtime(api: queueAPI, scenicId: currentScenicId, userId: currentUserId)
}
.onAppear {
queueRuntime.setSuspendedByQueueScreen(true)
}
.onDisappear {
viewModel.stopRealtime()
queueRuntime.setSuspendedByQueueScreen(false)
}
.refreshable {
await reload()
}
.onChange(of: viewModel.remoteCallAnnouncement) { _, announcement in
if let announcement {
toastCenter.show("远端已叫号 \(announcement.queueCode.isEmpty ? "当前游客" : announcement.queueCode)")
}
}
.onChange(of: viewModel.errorMessage) { _, message in
if let message { toastCenter.show(message) }
}
}
private var reloadTaskID: String {
"\(currentScenicId ?? 0)-\(scenicSpotContext.spots.map(\.id).description)"
}
private var header: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
HStack(spacing: AppMetrics.Spacing.medium) {
metricCard(title: "在排人数", value: "\(viewModel.queueCount)", icon: "person.3.fill", color: AppDesign.primary)
metricCard(title: "平均等待", value: "\(viewModel.avgWaitMinText)", icon: "clock.fill", color: AppDesign.warning)
}
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(accountContext.currentScenic?.name ?? "未选择景区")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text("打卡点:\(viewModel.currentSpotName)")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Text(viewModel.lastSyncTimeText)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}
private var gateCard: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
Image(systemName: "mappin.and.ellipse")
.font(.system(size: 42, weight: .semibold))
.foregroundStyle(AppDesign.primary)
Text("请先设置排队打卡点")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text("排队列表需要固定打卡点后才能加载。")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
NavigationLink {
ScenicQueueSettingsView()
} label: {
Label("前往设置", systemImage: "gearshape")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
}
.buttonStyle(.borderedProminent)
}
.padding(AppMetrics.Spacing.large)
.frame(maxWidth: .infinity)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private var listSection: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
Picker("排队类型", selection: Binding(
get: { viewModel.selectedListType },
set: { type in
Task { await viewModel.selectListType(type, api: queueAPI, scenicId: currentScenicId, userId: currentUserId) }
}
)) {
ForEach(QueueListType.allCases) { type in
Text(type.title).tag(type)
}
}
.pickerStyle(.segmented)
if viewModel.loading && viewModel.items.isEmpty {
ProgressView("加载中...")
.frame(maxWidth: .infinity, minHeight: 240)
} else if viewModel.items.isEmpty {
ContentUnavailableView(viewModel.selectedListType.emptyText, systemImage: "person.crop.circle.badge.questionmark")
.frame(maxWidth: .infinity, minHeight: 240)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
} else {
LazyVStack(spacing: AppMetrics.Spacing.medium) {
ForEach(Array(viewModel.items.enumerated()), id: \.element.id) { index, item in
QueueTicketCard(
item: item,
index: index + 1,
listType: viewModel.selectedListType,
processing: processingId == item.id,
onCall: { pendingAction = QueueActionConfirmation(kind: item.isCalled == 1 ? .recall : .call, item: item) },
onPass: { pendingAction = QueueActionConfirmation(kind: .pass, item: item) },
onFinish: { pendingAction = QueueActionConfirmation(kind: .finish, item: item) },
onRequeue: { pendingAction = QueueActionConfirmation(kind: .requeue, item: item) },
onMark: { markTarget = item }
)
.onAppear {
if item.id == viewModel.items.last?.id {
Task { await viewModel.loadMore(api: queueAPI, scenicId: currentScenicId, userId: currentUserId) }
}
}
}
if viewModel.loadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, AppMetrics.Spacing.medium)
}
}
}
}
}
private func metricCard(title: String, value: String, icon: String, color: Color) -> some View {
HStack(spacing: AppMetrics.Spacing.medium) {
Image(systemName: icon)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(color)
.frame(width: 38, height: 38)
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
private func reload() async {
await viewModel.reload(
api: queueAPI,
scenicId: currentScenicId,
userId: currentUserId,
spots: scenicSpotContext.spots
)
}
private func perform(_ action: QueueActionConfirmation) async {
processingId = action.item.id
defer { processingId = nil }
do {
switch action.kind {
case .call, .recall:
try await viewModel.callQueue(api: queueAPI, id: action.item.id)
case .pass:
try await viewModel.passQueue(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, id: action.item.id)
case .finish:
_ = try await viewModel.finishQueue(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, id: action.item.id)
case .requeue:
try await viewModel.requeue(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, id: action.item.id, operatorId: Int(currentUserId ?? "") ?? 0)
}
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func userMark(item: QueueItem, markAsFreelance: Bool, banDays: Int) async -> Bool {
guard let scenicId = currentScenicId else { return false }
processingId = item.id
defer { processingId = nil }
do {
try await viewModel.userMark(
api: queueAPI,
scenicId: scenicId,
userId: currentUserId,
request: ScenicQueueUserMarkRequest(
uid: item.uid,
scenicId: Int64(scenicId),
markAsFreelancePhotog: markAsFreelance ? 1 : 0,
queueBanDays: markAsFreelance ? banDays : nil,
operatorId: Int(currentUserId ?? "")
)
)
return true
} catch {
toastCenter.show(error.localizedDescription)
return false
}
}
}
private struct QueueTicketCard: View {
let item: QueueItem
let index: Int
let listType: QueueListType
let processing: Bool
let onCall: () -> Void
let onPass: () -> Void
let onFinish: () -> Void
let onRequeue: () -> Void
let onMark: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(item.queueCode.isEmpty ? "\(index)" : item.queueCode)
.font(.system(size: 26, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
if item.isCalled == 1 || item.statusText.contains("已叫号") {
tag("已叫号", color: AppDesign.primary)
}
if item.shouldShowIdentityTag {
tag(item.identityTag, color: AppDesign.warning)
}
}
Text(item.phoneMasked)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 5) {
Text(item.statusText.isEmpty ? "--" : item.statusText)
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.primary)
Text(item.queueTimeDisplay)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
}
HStack(spacing: AppMetrics.Spacing.small) {
info("等待", "\(item.waitMin)")
info("前方", "\(item.aheadCount)")
info("今日", "\(item.queueCountToday)")
}
if !item.queueBanLabel.isEmpty || item.isMissRequeueRecord {
HStack(spacing: 8) {
if !item.queueBanLabel.isEmpty {
tag(item.queueBanLabel, color: AppDesign.warning)
}
if item.isMissRequeueRecord {
tag(item.missRequeueText.isEmpty ? "重排" : item.missRequeueText, color: AppDesign.warning)
}
}
}
HStack(spacing: AppMetrics.Spacing.small) {
if listType == .queueing {
actionButton(item.isCalled == 1 ? "重叫" : "叫号", icon: "speaker.wave.2.fill", action: onCall)
actionButton("过号", icon: "forward.end.fill", action: onPass)
actionButton("完成", icon: "checkmark.circle.fill", action: onFinish)
} else {
actionButton("重排", icon: "arrow.uturn.left", action: onRequeue)
}
actionButton("标记", icon: "person.badge.key.fill", action: onMark)
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
.opacity(processing ? 0.6 : 1)
}
private func info(_ title: String, _ value: String) -> some View {
VStack(spacing: 4) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
}
private func tag(_ text: String, color: Color) -> some View {
Text(text)
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.12), in: Capsule())
}
private func actionButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Label(title, systemImage: icon)
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.bordered)
}
}
private struct QueueUserMarkView: View {
let item: QueueItem
let onConfirm: (Bool, Int) async -> Bool
@Environment(\.dismiss) private var dismiss
@State private var markAsFreelance = true
@State private var queueBanDays = 7
@State private var submitting = false
var body: some View {
NavigationStack {
Form {
Section("用户") {
Text(item.queueCode.isEmpty ? "当前游客" : item.queueCode)
Text(item.phoneMasked)
}
Section("标记") {
Toggle("标记为打野摄影师", isOn: $markAsFreelance)
Stepper("限制排队 \(queueBanDays)", value: $queueBanDays, in: 0...999)
}
}
.navigationTitle("用户标记")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(submitting ? "提交中" : "提交") {
Task {
submitting = true
let ok = await onConfirm(markAsFreelance, queueBanDays)
submitting = false
if ok { dismiss() }
}
}
.disabled(submitting)
}
}
}
}
}
private struct QueueActionConfirmation: Identifiable {
enum Kind {
case call
case recall
case pass
case finish
case requeue
}
let kind: Kind
let item: QueueItem
var id: String { "\(kind)-\(item.id)" }
var title: String {
switch kind {
case .call: return "确认叫号"
case .recall: return "确认重新叫号"
case .pass: return "确认过号"
case .finish: return "确认完成"
case .requeue: return "确认重新排队"
}
}
var message: String {
let queueCode = item.queueCode.isEmpty ? "该游客" : item.queueCode
switch kind {
case .call:
return "确定要叫号 \(queueCode) 吗?"
case .recall:
return "确定要重新叫号 \(queueCode) 吗?"
case .pass:
return "确定将 \(queueCode) 标记为过号吗?"
case .finish:
return "确定 \(queueCode) 已完成拍摄吗?"
case .requeue:
return "确定将 \(queueCode) 重新插入当前排队队列吗?"
}
}
}

View File

@ -0,0 +1,343 @@
//
// ScenicQueueSettingsViews.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import SwiftUI
///
struct ScenicQueueSettingsView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext
@Environment(ScenicQueueAPI.self) private var queueAPI
@Environment(ScenicQueueRuntime.self) private var queueRuntime
@Environment(ToastCenter.self) private var toastCenter
@State private var viewModel = ScenicQueueSettingsViewModel()
@State private var selectedTab: ScenicQueueSettingsTab = .basic
@State private var qrcodeURL: String?
@State private var saving = false
private var currentScenicId: Int? { accountContext.currentScenic?.id }
private var currentUserId: String? { accountContext.profile?.userId }
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
Picker("设置分组", selection: $selectedTab) {
ForEach(ScenicQueueSettingsTab.allCases) { tab in
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
switch selectedTab {
case .basic:
basicSection
case .notice:
noticeSection
case .voice:
voiceSection
case .logs:
logsSection
}
}
.padding(AppMetrics.Spacing.pageHorizontal)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle("排队设置")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
Task { await fetchQRCode() }
} label: {
Image(systemName: "qrcode")
}
.accessibilityLabel("取号二维码")
Button(saving ? "保存中" : "保存") {
Task { await save() }
}
.disabled(saving || viewModel.loading)
}
}
.sheet(item: Binding(
get: { qrcodeURL.map(QueueQRCodeSheetItem.init(url:)) },
set: { qrcodeURL = $0?.url }
)) { item in
QueueQRCodeView(url: item.url) {
await saveQRCode(url: item.url)
}
}
.task(id: taskID) {
await viewModel.load(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, spots: scenicSpotContext.spots)
await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId)
}
.onChange(of: viewModel.message) { _, message in
if let message { toastCenter.show(message) }
}
}
private var taskID: String {
"\(currentScenicId ?? 0)-\(scenicSpotContext.spots.map(\.id).description)"
}
private var basicSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
sectionTitle("基础设置")
Picker("打卡点", selection: Binding(
get: { viewModel.selectedSpotId ?? 0 },
set: { id in
viewModel.selectedSpotId = id == 0 ? nil : id
Task { await viewModel.loadSelectedSpotSetting(api: queueAPI, scenicId: currentScenicId, userId: currentUserId) }
}
)) {
Text("请选择").tag(0)
ForEach(viewModel.scenicSpots) { spot in
Text(spot.name).tag(spot.id)
}
}
.pickerStyle(.menu)
.frame(maxWidth: .infinity, alignment: .leading)
Toggle("开放排队", isOn: $viewModel.queueOpen)
DatePicker("营业开始", selection: $viewModel.businessStartTime, displayedComponents: .hourAndMinute)
DatePicker("营业结束", selection: $viewModel.businessEndTime, displayedComponents: .hourAndMinute)
field("最大排队范围", text: $viewModel.maxQueueRangeKm, keyboard: .decimalPad, unit: "公里")
.onChange(of: viewModel.maxQueueRangeKm) { _, value in viewModel.updateMaxQueueRange(value) }
field("排队次数限制", text: $viewModel.localQueueLimit, keyboard: .numberPad, unit: "")
field("过号顺延", text: $viewModel.localPassDelay, keyboard: .numberPad, unit: "")
field("拍摄分钟", text: $viewModel.photoEstimateMin, keyboard: .numberPad, unit: "")
field("拍摄秒数", text: $viewModel.photoEstimateSec, keyboard: .numberPad, unit: "")
Toggle("展示开始拍摄按钮", isOn: $viewModel.showStartShootingButton)
field("自动叫号后续", text: $viewModel.autoCallAheadCount, keyboard: .numberPad, unit: "")
Toggle("快捷叫号按钮", isOn: $viewModel.quickCallButtonEnabled)
Toggle("预备叫号按钮", isOn: $viewModel.prepareCallButtonEnabled)
}
.cardStyle()
}
private var noticeSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
sectionTitle("通知规则")
field("第一次通知阈值", text: $viewModel.firstAhead, keyboard: .numberPad, unit: "")
Toggle("第一次短信通知", isOn: $viewModel.firstSms)
Toggle("第一次电话通知", isOn: $viewModel.firstPhone)
Divider()
field("第二次通知阈值", text: $viewModel.secondAhead, keyboard: .numberPad, unit: "")
Toggle("第二次短信通知", isOn: $viewModel.secondSms)
Toggle("第二次电话通知", isOn: $viewModel.secondPhone)
}
.cardStyle()
}
private var voiceSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
sectionTitle("语音播报")
Toggle("本地语音合成提醒", isOn: Binding(
get: { queueRuntime.voiceEnabled },
set: { queueRuntime.voiceEnabled = $0 }
))
Toggle("后台短时轮询", isOn: Binding(
get: { queueRuntime.backgroundPollingEnabled },
set: { queueRuntime.backgroundPollingEnabled = $0 }
))
field("播报间隔", text: $viewModel.broadcastIntervalSec, keyboard: .numberPad, unit: "")
field("读秒阈值", text: $viewModel.countdownThresholdSec, keyboard: .numberPad, unit: "")
TextEditor(text: $viewModel.customTtsText)
.frame(minHeight: 88)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 88)
HStack {
Button("试听") { viewModel.playCustomText() }
.buttonStyle(.bordered)
Button("保存为预设") { viewModel.saveCustomTextAsPreset() }
.buttonStyle(.borderedProminent)
}
Button("播放测试音") { viewModel.playTestSound() }
.buttonStyle(.bordered)
if !viewModel.presetVoices.isEmpty {
ForEach(Array(viewModel.presetVoices.enumerated()), id: \.offset) { index, text in
HStack {
Text(text)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(2)
Spacer()
Button {
viewModel.deletePresetVoice(at: index)
} label: {
Image(systemName: "trash")
}
}
.padding(.vertical, 6)
}
}
}
.cardStyle()
}
private var logsSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
HStack {
sectionTitle("配置日志")
Spacer()
Button {
Task { await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId) }
} label: {
Image(systemName: "arrow.clockwise")
}
}
if viewModel.logs.isEmpty {
ContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath")
.frame(maxWidth: .infinity, minHeight: 180)
} else {
ForEach(viewModel.logs) { log in
VStack(alignment: .leading, spacing: 6) {
Text(log.displayText.isEmpty ? log.summary : log.displayText)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
Text("\(log.scenicSpotName) \(log.operatorName) \(log.createdAt)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
}
}
}
.cardStyle()
}
private func field(_ title: String, text: Binding<String>, keyboard: UIKeyboardType, unit: String) -> some View {
HStack(spacing: AppMetrics.Spacing.medium) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.frame(width: 104, alignment: .leading)
TextField("", text: text)
.keyboardType(keyboard)
.textFieldStyle(.plain)
.multilineTextAlignment(.trailing)
Text(unit)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
}
private func sectionTitle(_ title: String) -> some View {
Text(title)
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
}
private func fetchQRCode() async {
do {
qrcodeURL = try await viewModel.fetchQRCode(api: queueAPI, scenicId: currentScenicId)
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func saveQRCode(url: String) async {
do {
try await viewModel.saveQRCodeToPhotoLibrary(qrcodeURL: url)
toastCenter.show("二维码已保存")
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func save() async {
saving = true
defer { saving = false }
do {
try await viewModel.save(api: queueAPI, scenicId: currentScenicId, userId: currentUserId)
toastCenter.show("排队设置已保存")
await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId)
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
private enum ScenicQueueSettingsTab: CaseIterable, Identifiable {
case basic
case notice
case voice
case logs
var id: Self { self }
var title: String {
switch self {
case .basic: return "基础"
case .notice: return "通知"
case .voice: return "语音"
case .logs: return "日志"
}
}
}
private struct QueueQRCodeSheetItem: Identifiable {
let url: String
var id: String { url }
}
private struct QueueQRCodeView: View {
let url: String
let onSave: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var saving = false
var body: some View {
NavigationStack {
VStack(spacing: AppMetrics.Spacing.large) {
RemoteImage(urlString: url, contentMode: .fit) {
Image(systemName: "qrcode")
.font(.system(size: 80))
.foregroundStyle(AppDesign.placeholder)
}
.frame(maxWidth: .infinity)
.frame(height: 280)
.padding(AppMetrics.Spacing.large)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
Button {
Task {
saving = true
await onSave()
saving = false
}
} label: {
Label(saving ? "保存中" : "保存到相册", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
}
.buttonStyle(.borderedProminent)
.disabled(saving)
}
.padding(AppMetrics.Spacing.pageHorizontal)
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle("取号二维码")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") { dismiss() }
}
}
}
}
}
private extension View {
func cardStyle() -> some View {
padding(AppMetrics.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
}