新增景区排队管理功能

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

View File

@ -0,0 +1,215 @@
//
// ScenicQueueAPI.swift
// suixinkan
//
import Foundation
@MainActor
/// API 便 ViewModel
protocol ScenicQueueAPIProtocol {
func saveSetting(_ request: ScenicQueueSaveSettingRequest) async throws
func call(recordId: Int64) async throws -> ScenicQueueCallData
func pass(recordId: Int64) async throws -> ScenicQueuePassData
func finish(recordId: Int64) async throws -> ScenicQueueFinishData
func userMark(_ request: ScenicQueueUserMarkRequest) async throws
func requeueInsertBefore(recordId: Int64, operatorId: Int) async throws
func shootQueueQrcode(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueShootQueueQrcodeData
func setting(scenicId: Int64?, scenicSpotId: Int64?) async throws -> ScenicQueueSettingData
func stats(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueStatsData
func home(scenicId: Int64, scenicSpotId: Int64, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData
func settingChangeLog(scenicId: Int64, scenicSpotId: Int64?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse
func socketToken() async throws -> SocketTokenResponse
func aliyunSTS(bucket: String) async throws -> AliyunOSSResponse
}
@MainActor
/// API Android `NetworkApi` scenic-queue
final class ScenicQueueAPI: ScenicQueueAPIProtocol {
private let client: APIClient
init(client: APIClient) {
self.client = client
}
///
func saveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/scenic-queue/save-setting", body: request)
)
}
///
func call(recordId: Int64) async throws -> ScenicQueueCallData {
try await client.send(
APIRequest(method: .post, path: "/api/app/scenic-queue/call", body: ScenicQueueIdRequest(id: recordId))
)
}
///
func pass(recordId: Int64) async throws -> ScenicQueuePassData {
try await client.send(
APIRequest(method: .post, path: "/api/app/scenic-queue/pass", body: ScenicQueueIdRequest(id: recordId))
)
}
///
func finish(recordId: Int64) async throws -> ScenicQueueFinishData {
try await client.send(
APIRequest(method: .post, path: "/api/app/scenic-queue/finish", body: ScenicQueueIdRequest(id: recordId))
)
}
///
func userMark(_ request: ScenicQueueUserMarkRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(method: .post, path: "/api/app/scenic-queue/user-mark", body: request)
)
}
///
func requeueInsertBefore(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 shootQueueQrcode(scenicId: Int64, scenicSpotId: Int64) 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 setting(scenicId: Int64?, scenicSpotId: Int64?) async throws -> ScenicQueueSettingData {
var items: [URLQueryItem] = []
if let scenicId {
items.append(URLQueryItem(name: "scenic_id", value: String(scenicId)))
}
if let scenicSpotId {
items.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)))
}
return try await client.send(
APIRequest(method: .get, path: "/api/app/scenic-queue/setting", queryItems: items)
)
}
///
func stats(scenicId: Int64, scenicSpotId: Int64) async throws -> ScenicQueueStatsData {
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)),
]
)
)
}
///
func home(scenicId: Int64, scenicSpotId: Int64, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData {
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(page)),
URLQueryItem(name: "page_size", value: String(pageSize)),
]
)
)
}
///
func settingChangeLog(scenicId: Int64, scenicSpotId: Int64?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData {
var items = [
URLQueryItem(name: "scenic_id", value: String(scenicId)),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "page_size", value: String(pageSize)),
]
if let scenicSpotId {
items.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)))
}
return try await client.send(
APIRequest(method: .get, path: "/api/app/scenic-queue/setting-change-log", queryItems: items)
)
}
///
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
queryItems: [URLQueryItem(name: "scenic_id", value: scenicId)]
)
)
}
/// WebSocket token
func socketToken() async throws -> SocketTokenResponse {
try await client.send(APIRequest(method: .get, path: "/api/app/socket-token"))
}
/// STS
func aliyunSTS(bucket: String = "vipsky") async throws -> AliyunOSSResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/app/config/get-sts-token",
queryItems: [URLQueryItem(name: "bucket", value: bucket)]
)
)
}
}
/// id
struct ScenicQueueIdRequest: Encodable, Equatable {
let id: Int64
}
///
struct ScenicQueueRequeueInsertBeforeRequest: Encodable, Equatable {
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"
}
}

View File

@ -0,0 +1,466 @@
//
// ScenicQueueAPIModels.swift
// suixinkan
//
import Foundation
/// GET /api/app/scenic-queue/queue-stats
struct ScenicQueueStatsData: Decodable, Equatable {
let queueCount: Int
let avgWaitMin: Double
let time: String?
enum CodingKeys: String, CodingKey {
case queueCount = "queue_count"
case avgWaitMin = "avg_wait_min"
case time
}
}
/// GET /api/app/scenic-queue/home
struct ScenicQueueHomeData: Decodable, Equatable {
let stats: ScenicQueueHomeStats?
let list: ScenicQueueHomeListBlock?
let time: String?
}
/// home stats
struct ScenicQueueHomeStats: Decodable, Equatable {
let type: Int
}
/// home
struct ScenicQueueHomeListBlock: Decodable, Equatable {
let list: [ScenicQueueHomeItem]
let total: Int
let page: Int
let pageSize: Int
enum CodingKeys: String, CodingKey {
case list
case total
case page
case pageSize = "page_size"
}
}
/// home
struct ScenicQueueHomeItem: Decodable, Equatable {
let id: Int64
let queueCode: String
let mobile: String
let status: Int
let statusText: String
let waitMin: Int
let aheadCount: Int
let isCalled: Int
let createdAt: String?
let calledAt: String?
let expiredAt: String?
let finishedAt: String?
let identityTag: String?
let queueBanLabel: String?
let queueBanDays: Int?
let queueTime: String?
let queueCountToday: Int?
let uid: Int64
let markAsPhotog: Int
let markAsFreelancePhotog: Int
let isMissRequeue: Int
let isMissRequeueText: String?
enum CodingKeys: String, CodingKey {
case id
case queueCode = "queue_code"
case mobile
case status
case statusText = "status_text"
case waitMin = "wait_min"
case aheadCount = "ahead_count"
case isCalled = "is_called"
case createdAt = "created_at"
case calledAt = "called_at"
case expiredAt = "expired_at"
case finishedAt = "finished_at"
case identityTag = "identity_tag"
case queueBanLabel = "queue_ban_label"
case queueBanDays = "queue_ban_days"
case queueTime = "queue_time"
case queueCountToday = "queue_count_today"
case uid
case markAsPhotog = "mark_as_photog"
case markAsFreelancePhotog = "mark_as_freelance_photog"
case isMissRequeue = "is_miss_requeue"
case isMissRequeueText = "is_miss_requeue_text"
}
}
extension ScenicQueueHomeItem {
///
func toCurrentTicket() -> ScenicQueueTicket {
ScenicQueueTicket(
recordId: id,
queueNo: queueCode,
phoneMasked: Self.maskMobile(mobile),
dialPhoneDigits: mobile.filter(\.isNumber),
status: isCalled == 1 || statusText.contains("已叫") ? .called : .waiting,
statusText: statusText.isEmpty ? (isCalled == 1 ? "已叫号" : "待叫号") : statusText,
waitMinutes: max(0, waitMin),
peopleAhead: max(0, aheadCount),
showCompleteAction: isCalled == 1 || statusText.contains("已叫"),
queueBanLabel: queueBanLabel ?? "",
identityTag: identityTag ?? "",
queueTimeDisplay: Self.formatQueueTime(queueTime ?? createdAt),
queueCountToday: queueCountToday ?? 0,
uid: uid,
markAsPhotog: markAsPhotog,
markAsFreelancePhotog: markAsFreelancePhotog,
isMissRequeue: isMissRequeue == 1,
missRequeueText: isMissRequeueText ?? ""
)
}
///
func toSkippedTicket() -> ScenicQueueSkippedTicket {
ScenicQueueSkippedTicket(
recordId: id,
queueNo: queueCode,
phoneMasked: Self.maskMobile(mobile),
dialPhoneDigits: mobile.filter(\.isNumber),
statusText: statusText.isEmpty ? "已过号" : statusText,
skippedTimeDisplay: Self.formatQueueTime(expiredAt ?? finishedAt ?? calledAt),
queueBanLabel: queueBanLabel ?? "",
isMissRequeue: isMissRequeue == 1,
missRequeueText: isMissRequeueText ?? ""
)
}
private static func maskMobile(_ raw: String) -> String {
let digits = raw.filter(\.isNumber)
guard digits.count >= 7 else { return raw }
let start = digits.prefix(3)
let end = digits.suffix(4)
return "\(start)****\(end)"
}
private static func formatQueueTime(_ raw: String?) -> String {
let text = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !text.isEmpty else { return "--" }
let normalized = text.replacingOccurrences(of: "T", with: " ").replacingOccurrences(of: "Z", with: "")
let inputFormats = ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"]
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
for format in inputFormats {
formatter.dateFormat = format
if let date = formatter.date(from: normalized) {
formatter.dateFormat = "MM-dd HH:mm"
return formatter.string(from: date)
}
}
return String(text.prefix(16))
}
}
/// POST /api/app/scenic-queue/call data
struct ScenicQueueCallData: Decodable, Equatable {
let id: Int64
let status: Int
let statusText: String
let calledAt: String?
enum CodingKeys: String, CodingKey {
case id
case status
case statusText = "status_text"
case calledAt = "called_at"
}
}
/// POST /api/app/scenic-queue/pass data
struct ScenicQueuePassData: Decodable, Equatable {
let id: Int64
let status: Int
let statusText: String
enum CodingKeys: String, CodingKey {
case id
case status
case statusText = "status_text"
}
}
/// POST /api/app/scenic-queue/finish data
struct ScenicQueueFinishData: Decodable, Equatable {
let id: Int64
let status: Int
let statusText: String
enum CodingKeys: String, CodingKey {
case id
case status
case statusText = "status_text"
}
}
/// GET /api/app/scenic-queue/setting
struct ScenicQueueSettingData: Decodable, Equatable {
let exists: Bool
let setting: ScenicQueueSettingItem?
let time: String?
}
///
struct ScenicQueueSettingItem: Codable, Equatable {
let id: Int64?
let scenicId: Int64?
let scenicSpotId: Int64?
let scenicSpotName: String?
let photoEstimateMin: Int?
let photoEstimateSec: Int?
let firstNoticeThresholdPos: Int?
let firstNoticeSmsEnabled: Int?
let firstNoticeCallEnabled: Int?
let secondNoticeThresholdPos: Int?
let secondNoticeSmsEnabled: Int?
let secondNoticeCallEnabled: Int?
let countdownBroadcastIntervalSec: Int?
let countdownReadableThresholdSec: Int?
let queueDistanceMeter: Int?
let queueTakeLimit: Int?
let missCallRequeueOffset: Int?
let showStartShootButton: Int?
let autoCallNextCount: Int?
let businessStartTime: String?
let businessEndTime: String?
let voiceBroadcasts: [ScenicQueueVoiceBroadcastItem]?
let status: Int?
let remark: String?
let createdAt: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case scenicId = "scenic_id"
case scenicSpotId = "scenic_spot_id"
case scenicSpotName = "scenic_spot_name"
case photoEstimateMin = "photo_estimate_min"
case photoEstimateSec = "photo_estimate_sec"
case firstNoticeThresholdPos = "first_notice_threshold_pos"
case firstNoticeSmsEnabled = "first_notice_sms_enabled"
case firstNoticeCallEnabled = "first_notice_call_enabled"
case secondNoticeThresholdPos = "second_notice_threshold_pos"
case secondNoticeSmsEnabled = "second_notice_sms_enabled"
case secondNoticeCallEnabled = "second_notice_call_enabled"
case countdownBroadcastIntervalSec = "countdown_broadcast_interval_sec"
case countdownReadableThresholdSec = "countdown_readable_threshold_sec"
case queueDistanceMeter = "queue_distance_meter"
case queueTakeLimit = "queue_take_limit"
case missCallRequeueOffset = "miss_call_requeue_offset"
case showStartShootButton = "show_start_shoot_button"
case autoCallNextCount = "auto_call_next_count"
case businessStartTime = "business_start_time"
case businessEndTime = "business_end_time"
case voiceBroadcasts = "voice_broadcasts"
case status
case remark
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
extension ScenicQueueSettingItem {
///
func toSnapshot(fallback: ScenicQueueSettingsSnapshot = ScenicQueueSettingsSnapshot()) -> ScenicQueueSettingsSnapshot {
ScenicQueueSettingsSnapshot(
shootMinute: (photoEstimateMin ?? 0).clamped(to: 0...30),
shootSecond: (photoEstimateSec ?? 0).clamped(to: 0...59),
firstAheadCount: (firstNoticeThresholdPos ?? 0).clamped(to: 0...50),
firstSms: (firstNoticeSmsEnabled ?? 0) == 1,
firstPhone: (firstNoticeCallEnabled ?? 0) == 1,
secondAheadCount: (secondNoticeThresholdPos ?? 0).clamped(to: 0...50),
secondSms: (secondNoticeSmsEnabled ?? 0) == 1,
secondPhone: (secondNoticeCallEnabled ?? 0) == 1,
broadcastIntervalSec: ScenicQueueSettingItem.normalizedBroadcastInterval(countdownBroadcastIntervalSec),
countdownThresholdSec: ScenicQueueSettingItem.normalizedReadableThreshold(countdownReadableThresholdSec),
queueDistanceMeter: (queueDistanceMeter ?? 0).clamped(to: 0...9_999_990),
queueTakeLimit: (queueTakeLimit ?? 0).clamped(to: 0...999_999),
missCallRequeueOffset: (missCallRequeueOffset ?? 1).clamped(to: 1...9_999),
showStartShootingButton: showStartShootButton.map { $0 == 1 } ?? fallback.showStartShootingButton,
autoCallAheadCount: (autoCallNextCount ?? fallback.autoCallAheadCount).clamped(to: 0...5),
quickCallButtonEnabled: fallback.quickCallButtonEnabled,
prepareCallButtonEnabled: fallback.prepareCallButtonEnabled,
businessOpen: (status ?? 1) == 1,
businessStartTime: businessStartTime?.nonEmptyTrimmed ?? "10:00",
businessEndTime: businessEndTime?.nonEmptyTrimmed ?? "20:00"
)
}
private static func normalizedBroadcastInterval(_ value: Int?) -> Int {
guard let value, 40...60 ~= value else { return 50 }
return value
}
private static func normalizedReadableThreshold(_ value: Int?) -> Int {
guard let value, 10...30 ~= value else { return 15 }
return value
}
}
/// POST /api/app/scenic-queue/save-setting
struct ScenicQueueSaveSettingRequest: Encodable, Equatable {
let scenicId: Int64
let scenicSpotId: Int64
let photoEstimateMin: Int?
let photoEstimateSec: Int?
let firstNoticeThresholdPos: Int?
let firstNoticeSmsEnabled: Int?
let firstNoticeCallEnabled: Int?
let secondNoticeThresholdPos: Int?
let secondNoticeSmsEnabled: Int?
let secondNoticeCallEnabled: Int?
let countdownBroadcastIntervalSec: Int?
let countdownReadableThresholdSec: Int?
let queueDistanceMeter: Int?
let queueTakeLimit: Int?
let missCallRequeueOffset: Int?
let showStartShootButton: Int?
let autoCallNextCount: Int?
let businessStartTime: String?
let businessEndTime: String?
let voiceBroadcasts: [ScenicQueueVoiceBroadcastItem]
let status: Int?
let remark: String?
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case scenicSpotId = "scenic_spot_id"
case photoEstimateMin = "photo_estimate_min"
case photoEstimateSec = "photo_estimate_sec"
case firstNoticeThresholdPos = "first_notice_threshold_pos"
case firstNoticeSmsEnabled = "first_notice_sms_enabled"
case firstNoticeCallEnabled = "first_notice_call_enabled"
case secondNoticeThresholdPos = "second_notice_threshold_pos"
case secondNoticeSmsEnabled = "second_notice_sms_enabled"
case secondNoticeCallEnabled = "second_notice_call_enabled"
case countdownBroadcastIntervalSec = "countdown_broadcast_interval_sec"
case countdownReadableThresholdSec = "countdown_readable_threshold_sec"
case queueDistanceMeter = "queue_distance_meter"
case queueTakeLimit = "queue_take_limit"
case missCallRequeueOffset = "miss_call_requeue_offset"
case showStartShootButton = "show_start_shoot_button"
case autoCallNextCount = "auto_call_next_count"
case businessStartTime = "business_start_time"
case businessEndTime = "business_end_time"
case voiceBroadcasts = "voice_broadcasts"
case status
case remark
}
}
///
struct ScenicQueueVoiceBroadcastItem: Codable, Equatable {
let content: String?
let sortOrder: Int?
enum CodingKeys: String, CodingKey {
case content
case sortOrder = "sort_order"
}
}
/// GET /api/app/scenic-queue/shoot-queue-qrcode
struct ScenicQueueShootQueueQrcodeData: Decodable, Equatable {
let qrcodeUrl: String?
let scene: String?
let page: String?
let time: String?
enum CodingKeys: String, CodingKey {
case qrcodeUrl = "qrcode_url"
case scene
case page
case time
}
}
/// GET /api/app/scenic-queue/setting-change-log
struct ScenicQueueSettingChangeLogData: Decodable, Equatable {
let total: Int
let page: Int
let pageSize: Int
let list: [ScenicQueueSettingChangeLogItem]
enum CodingKeys: String, CodingKey {
case total
case page
case pageSize = "page_size"
case list
}
}
///
struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Hashable {
let id: Int64
let scenicId: Int?
let scenicSpotId: Int?
let scenicSpotName: String
let operatorId: Int?
let operatorName: String
let operatorPhoneTail: String
let summary: String
let displayText: String
let createdAt: String?
enum CodingKeys: String, CodingKey {
case id
case scenicId = "scenic_id"
case scenicSpotId = "scenic_spot_id"
case scenicSpotName = "scenic_spot_name"
case operatorId = "operator_id"
case operatorName = "operator_name"
case operatorPhoneTail = "operator_phone_tail"
case summary
case displayText = "display_text"
case createdAt = "created_at"
}
}
///
struct ScenicSpotListResponse: Decodable, Equatable {
let list: [ScenicSpotItem]
let total: Int?
enum CodingKeys: String, CodingKey {
case list
case total
}
}
///
struct ScenicSpotItem: Decodable, Equatable {
let id: Int64
let name: String
}
/// Socket token
struct SocketTokenResponse: Decodable, Equatable {
let socketToken: String
enum CodingKeys: String, CodingKey {
case socketToken = "socket_token"
}
}
private extension Int {
func clamped(to range: ClosedRange<Int>) -> Int {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
private extension String {
var nonEmptyTrimmed: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

View File

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

View File

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

View File

@ -0,0 +1,55 @@
//
// ScenicQueueQRCodeSaver.swift
// suixinkan
//
import Foundation
import Photos
import UIKit
///
enum ScenicQueueQRCodeSaver {
///
static func saveImage(from urlString: String) async throws {
guard let url = URL(string: urlString) else {
throw ScenicQueueQRCodeSaverError.invalidURL
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ScenicQueueQRCodeSaverError.invalidImage
}
try await saveToPhotoLibrary(image)
}
private static func saveToPhotoLibrary(_ image: UIImage) async throws {
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
if status == .notDetermined {
_ = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
}
guard PHPhotoLibrary.authorizationStatus(for: .addOnly) == .authorized
|| PHPhotoLibrary.authorizationStatus(for: .addOnly) == .limited else {
throw ScenicQueueQRCodeSaverError.denied
}
try await PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: image)
}
}
}
///
enum ScenicQueueQRCodeSaverError: LocalizedError {
case invalidURL
case invalidImage
case denied
var errorDescription: String? {
switch self {
case .invalidURL:
"服务端返回的二维码地址无效"
case .invalidImage:
"下载或保存二维码失败,请检查网络与存储权限"
case .denied:
"未授予权限,无法将小程序码保存到相册"
}
}
}

View File

@ -0,0 +1,73 @@
//
// ScenicQueueSocketClient.swift
// suixinkan
//
import Foundation
/// WebSocket
final class ScenicQueueSocketClient {
var onMessage: ((ScenicQueueSocketMessage) -> Void)?
private let environment: APIEnvironment
private let session: URLSession
private let decoder = JSONDecoder()
private var task: URLSessionWebSocketTask?
private var connectedSpotId: Int64?
init(environment: APIEnvironment = .current, session: URLSession = .shared) {
self.environment = environment
self.session = session
}
///
func connect(socketToken: String, scenicId: Int64, scenicSpotId: Int64) {
guard !socketToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
disconnect()
connectedSpotId = scenicSpotId
let webSocketTask = session.webSocketTask(with: environment.webSocketURL)
task = webSocketTask
webSocketTask.resume()
sendSubscribePayloads(scenicSpotId: scenicSpotId)
receiveLoop()
}
///
func disconnect() {
task?.cancel(with: .normalClosure, reason: nil)
task = nil
connectedSpotId = nil
}
private func sendSubscribePayloads(scenicSpotId: Int64) {
[
#"{"type":306,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#,
#"{"type":307,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#,
].forEach { payload in
task?.send(.string(payload)) { _ in }
}
}
private func receiveLoop() {
task?.receive { [weak self] result in
guard let self else { return }
switch result {
case .success(let message):
if case .string(let text) = message {
self.handle(text)
}
self.receiveLoop()
case .failure:
self.task = nil
}
}
}
private func handle(_ text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8) else { return }
guard let message = try? decoder.decode(ScenicQueueSocketMessage.self, from: data) else { return }
guard message.isQueueEvent else { return }
onMessage?(message)
}
}

View File

@ -0,0 +1,228 @@
//
// ScenicQueueTTSManager.swift
// suixinkan
//
import AVFoundation
import Foundation
/// TTS SDK
protocol ScenicQueueTTSManaging: AnyObject {
///
var customTextLooping: Bool { get }
/// TTS
func prepareQueueTTSEngine(api: ScenicQueueAPI?) async
///
func startQueueCallLoop(queueNo: String)
///
func stopQueueCallLoop()
///
@discardableResult
func speakPlainTextOnce(_ text: String) async -> Bool
///
@discardableResult
func startCustomTextLoop(_ text: String) async -> Bool
///
func stopCustomTextLoop()
///
func interruptShootingAnnouncement()
///
func estimateAnnouncementDurationMillis(_ text: String) -> Int64
}
/// NLS TTS
///
/// iOS NativeNui SDK STS/NLS SDK
/// SDK framework `prepareNativeEngineIfAvailable` NativeNui
final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate {
static let shared = AliyunNLSTTSManager()
private enum LoopKind {
case queueCall(String)
case custom(String)
}
private let synthesizer = AVSpeechSynthesizer()
private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts")
private var activeLoop: LoopKind?
private var currentContinuation: CheckedContinuation<Void, Never>?
private var nlsToken: String?
private var lastCredentials: AliyunOSSCredentials?
private(set) var customTextLooping = false
override init() {
super.init()
synthesizer.delegate = self
}
func prepareQueueTTSEngine(api: ScenicQueueAPI?) async {
guard let api else { return }
do {
let sts = try await api.aliyunSTS(bucket: "vipsky")
lastCredentials = sts.credentials
nlsToken = nil
prepareNativeEngineIfAvailable(credentials: sts.credentials)
} catch {
// Voice playback falls back to AVSpeechSynthesizer if NLS warm-up fails.
}
}
func startQueueCallLoop(queueNo: String) {
let text = Self.callAnnouncementText(queueNo: queueNo)
loopQueue.async { [weak self] in
guard let self else { return }
self.activeLoop = .queueCall(queueNo)
self.customTextLooping = false
Task { await self.runLoop(text: text, expected: .queueCall(queueNo)) }
}
}
func stopQueueCallLoop() {
loopQueue.async { [weak self] in
guard let self else { return }
if case .queueCall = self.activeLoop {
self.activeLoop = nil
}
self.stopSpeaking()
}
}
@discardableResult
func speakPlainTextOnce(_ text: String) async -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
await speak(trimmed)
return true
}
@discardableResult
func startCustomTextLoop(_ text: String) async -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
loopQueue.async { [weak self] in
guard let self else { return }
self.activeLoop = .custom(trimmed)
self.customTextLooping = true
Task { await self.runLoop(text: trimmed, expected: .custom(trimmed)) }
}
return true
}
func stopCustomTextLoop() {
loopQueue.async { [weak self] in
guard let self else { return }
if case .custom = self.activeLoop {
self.activeLoop = nil
}
self.customTextLooping = false
self.stopSpeaking()
}
}
func interruptShootingAnnouncement() {
stopSpeaking()
}
func estimateAnnouncementDurationMillis(_ text: String) -> Int64 {
let count = max(1, text.count)
return Int64(min(max(850 + count * 200, 900), 12_000))
}
/// TTS
static func formatQueueNoForTTS(_ queueNo: String) -> String {
let trimmed = queueNo.trimmingCharacters(in: .whitespacesAndNewlines)
let core = trimmed.hasSuffix("") ? String(trimmed.dropLast()) : trimmed
guard !core.isEmpty else { return trimmed.isEmpty ? "" : trimmed }
let pattern = #"^([A-Za-z]+)([0-9]+)$"#
if let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: core, range: NSRange(core.startIndex..., in: core)),
let lettersRange = Range(match.range(at: 1), in: core),
let digitsRange = Range(match.range(at: 2), in: core) {
return "\(core[lettersRange].uppercased())\(core[digitsRange])"
}
return "\(core)"
}
///
static func callAnnouncementText(queueNo: String) -> String {
"\(formatQueueNoForTTS(queueNo)),请到拍摄区域,准备拍摄。"
}
private func runLoop(text: String, expected: LoopKind) async {
while isLoopStillActive(expected) {
await speak(text)
guard isLoopStillActive(expected) else { break }
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
if case .custom = expected {
customTextLooping = false
}
}
private func isLoopStillActive(_ expected: LoopKind) -> Bool {
loopQueue.sync {
switch (activeLoop, expected) {
case (.queueCall(let lhs), .queueCall(let rhs)):
lhs == rhs
case (.custom(let lhs), .custom(let rhs)):
lhs == rhs
default:
false
}
}
}
private func speak(_ text: String) async {
await MainActor.run {
if synthesizer.isSpeaking {
synthesizer.stopSpeaking(at: .immediate)
}
}
await withCheckedContinuation { continuation in
currentContinuation = continuation
let utterance = AVSpeechUtterance(string: text)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
utterance.rate = AVSpeechUtteranceDefaultSpeechRate * 0.86
utterance.volume = 1.0
DispatchQueue.main.async {
self.synthesizer.speak(utterance)
}
}
}
private func stopSpeaking() {
DispatchQueue.main.async {
if self.synthesizer.isSpeaking {
self.synthesizer.stopSpeaking(at: .immediate)
}
self.resumeCurrentContinuationIfNeeded()
}
}
private func resumeCurrentContinuationIfNeeded() {
currentContinuation?.resume()
currentContinuation = nil
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
resumeCurrentContinuationIfNeeded()
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
resumeCurrentContinuationIfNeeded()
}
private func prepareNativeEngineIfAvailable(credentials: AliyunOSSCredentials) {
_ = NSClassFromString("NativeNui")
_ = credentials
}
}

View File

@ -0,0 +1,81 @@
//
// ScenicQueueSettingChangeLogViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel
final class ScenicQueueSettingChangeLogViewModel {
private enum Constants {
static let pageSize = 20
}
private(set) var items: [ScenicQueueSettingChangeLogItem] = []
private(set) var isRefreshing = false
private(set) var isLoadingMore = false
private(set) var canLoadMore = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private let appStore: AppStore
private var total = 0
private var lastLoadedPage = 0
init(appStore: AppStore = .shared) {
self.appStore = appStore
}
///
func refresh(api: ScenicQueueAPIProtocol) async {
await load(api: api, page: 1, append: false)
}
///
func loadMore(api: ScenicQueueAPIProtocol) async {
guard canLoadMore, !isRefreshing, !isLoadingMore else { return }
await load(api: api, page: lastLoadedPage + 1, append: true)
}
private func load(api: ScenicQueueAPIProtocol, page: Int, append: Bool) async {
guard appStore.currentScenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
if append {
isLoadingMore = true
} else {
isRefreshing = true
}
notifyStateChange()
defer {
if append { isLoadingMore = false } else { isRefreshing = false }
notifyStateChange()
}
let spotId = Int64(appStore.scenicQueuePunchSpotId)
do {
let data = try await api.settingChangeLog(
scenicId: Int64(appStore.currentScenicId),
scenicSpotId: spotId,
page: page,
pageSize: Constants.pageSize
)
if append {
let seen = Set(items.map(\.id))
items += data.list.filter { !seen.contains($0.id) }
} else {
items = data.list
}
total = max(0, data.total)
lastLoadedPage = max(1, data.page)
canLoadMore = items.count < total && !data.list.isEmpty
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -0,0 +1,496 @@
//
// ScenicQueueSettingsViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `ScenicQueueSettingsViewModel`
final class ScenicQueueSettingsViewModel {
private enum Limits {
static let remarkMaxLen = 255
static let shootMinute = 0...30
static let shootSecond = 0...59
static let ahead = 0...50
static let broadcastInterval = 40...60
static let readableThreshold = 10...30
static let queueDistance = 0...9_999_990
static let queueTakeLimit = 0...999_999
static let missCallRequeueOffset = 1...9_999
static let autoCallAheadCount = 0...5
}
private(set) var punchSpotOptions: [ScenicQueuePunchSpotOption] = []
private(set) var selectedPunchSpotId: String?
private(set) var selectedPunchSpotLabel: String
private(set) var shootMinute: Int
private(set) var shootSecond: Int
private(set) var firstAheadCount: Int
private(set) var firstSms: Bool
private(set) var firstPhone: Bool
private(set) var secondAheadCount: Int
private(set) var secondSms: Bool
private(set) var secondPhone: Bool
private(set) var broadcastIntervalSec: Int
private(set) var countdownThresholdSec: Int
private(set) var queueDistanceMeter: Int
private(set) var queueTakeLimit: Int
private(set) var missCallRequeueOffset: Int
private(set) var showStartShootingButton: Bool
private(set) var autoCallAheadCount: Int
private(set) var quickCallButtonEnabled: Bool
private(set) var prepareCallButtonEnabled: Bool
private(set) var businessOpen: Bool
private(set) var businessStartTime: String
private(set) var businessEndTime: String
private(set) var customTTSText: String
private(set) var presetVoices: [String]
private(set) var settingsTab: ScenicQueueSettingsTab = .basic
private(set) var highlightedInvalidFieldKeys: Set<String> = []
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onNavigateBack: (() -> Void)?
var onNavigateLog: (() -> Void)?
private let appStore: AppStore
private let ttsManager: ScenicQueueTTSManaging
private var punchSpotSettingRequestGeneration = 0
private var ttsLoopAnchorText: String?
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
self.appStore = appStore
self.ttsManager = ttsManager
let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot()
selectedPunchSpotId = appStore.scenicQueuePunchSpotId.nonEmptyTrimmed
selectedPunchSpotLabel = appStore.scenicQueuePunchSpotName
shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute)
shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond)
firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead)
firstSms = snapshot.firstSms
firstPhone = snapshot.firstPhone
secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead)
secondSms = snapshot.secondSms
secondPhone = snapshot.secondPhone
broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval)
countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold)
queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance)
queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit)
missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset)
showStartShootingButton = snapshot.showStartShootingButton
autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount)
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
businessOpen = snapshot.businessOpen
businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00")
businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00")
customTTSText = appStore.scenicQueueRemark
presetVoices = appStore.scenicQueuePresetVoices()
}
var selectedPunchSpotDisplayText: String {
if let selectedPunchSpotId,
let option = punchSpotOptions.first(where: { $0.id == selectedPunchSpotId }),
!option.label.isEmpty {
return option.label
}
if !selectedPunchSpotLabel.isEmpty {
return selectedPunchSpotLabel
}
if let selectedPunchSpotId, !selectedPunchSpotId.isEmpty {
return "打卡点ID \(selectedPunchSpotId)"
}
return "请选择打卡点"
}
func openSettingChangeLog() {
onNavigateLog?()
}
///
func loadPunchSpotOptions(api: ScenicQueueAPIProtocol) async {
guard appStore.currentScenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
do {
let response = try await api.scenicSpotListAll(scenicId: String(appStore.currentScenicId))
punchSpotOptions = response.list.map {
ScenicQueuePunchSpotOption(id: String($0.id), label: $0.name)
}
if punchSpotOptions.isEmpty {
onShowMessage?("暂无可选打卡点")
}
notifyStateChange()
} catch {
onShowMessage?(error.localizedDescription)
}
}
///
func setPunchSpot(_ option: ScenicQueuePunchSpotOption, api: ScenicQueueAPIProtocol) async {
selectedPunchSpotId = option.id
selectedPunchSpotLabel = option.label.trimmingCharacters(in: .whitespacesAndNewlines)
highlightedInvalidFieldKeys.remove("punchSpot")
notifyStateChange()
await fetchQueueSettingAfterPunchSpotSelected(option.id, api: api)
}
func setSettingsTab(_ tab: ScenicQueueSettingsTab) {
settingsTab = tab
notifyStateChange()
}
func setShootMinute(_ value: Int) { shootMinute = value.clamped(to: Limits.shootMinute); notifyStateChange() }
func setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); notifyStateChange() }
func setFirstAheadCount(_ value: Int) { firstAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
func setSecondAheadCount(_ value: Int) { secondAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() }
func setBroadcastIntervalSec(_ value: Int) { broadcastIntervalSec = value.clamped(to: Limits.broadcastInterval); notifyStateChange() }
func setCountdownThresholdSec(_ value: Int) { countdownThresholdSec = value.clamped(to: Limits.readableThreshold); notifyStateChange() }
func setQueueDistanceMeter(_ value: Int) { queueDistanceMeter = value.clamped(to: Limits.queueDistance); notifyStateChange() }
func setQueueTakeLimit(_ value: Int) { queueTakeLimit = value.clamped(to: Limits.queueTakeLimit); notifyStateChange() }
func setMissCallRequeueOffset(_ value: Int) { missCallRequeueOffset = value.clamped(to: Limits.missCallRequeueOffset); notifyStateChange() }
func setAutoCallAheadCount(_ value: Int) { autoCallAheadCount = value.clamped(to: Limits.autoCallAheadCount); notifyStateChange() }
func toggleShowStartShootingButton() { showStartShootingButton.toggle(); notifyStateChange() }
func toggleQuickCallButtonEnabled() { quickCallButtonEnabled.toggle(); notifyStateChange() }
func togglePrepareCallButtonEnabled() { prepareCallButtonEnabled.toggle(); notifyStateChange() }
func toggleBusinessOpen() { businessOpen.toggle(); notifyStateChange() }
func toggleFirstSms() { firstSms.toggle(); notifyStateChange() }
func toggleFirstPhone() { firstPhone.toggle(); notifyStateChange() }
func toggleSecondSms() { secondSms.toggle(); notifyStateChange() }
func toggleSecondPhone() { secondPhone.toggle(); notifyStateChange() }
func setBusinessStartTime(_ value: String) {
businessStartTime = Self.normalizedTime(value, fallback: businessStartTime)
notifyStateChange()
}
func setBusinessEndTime(_ value: String) {
businessEndTime = Self.normalizedTime(value, fallback: businessEndTime)
notifyStateChange()
}
func setCustomTTSText(_ value: String) {
customTTSText = String(value.prefix(Limits.remarkMaxLen))
notifyStateChange()
}
/// TTS
func toggleCustomTTSPlayback() {
if ttsManager.customTextLooping {
ttsManager.stopCustomTextLoop()
ttsLoopAnchorText = nil
notifyStateChange()
return
}
let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
onShowMessage?("请输入要播报的文字")
return
}
Task {
let ok = await ttsManager.startCustomTextLoop(text)
if ok {
ttsLoopAnchorText = text
} else {
ttsLoopAnchorText = nil
onShowMessage?("播报失败,请检查网络与语音服务")
}
notifyStateChange()
}
}
///
func playBluetoothTestSound() {
Task {
let ok = await ttsManager.speakPlainTextOnce("蓝牙音响测试音,请确认外放音量是否合适。")
if !ok {
onShowMessage?("测试音播放失败,请检查网络与语音服务")
}
}
}
///
func saveCurrentTextAsPresetVoiceLocal() {
guard appStore.currentScenicId > 0, selectedPunchSpotId?.nonEmptyTrimmed != nil else {
onShowMessage?("请先选择打卡点")
return
}
let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
onShowMessage?("请输入要播报的文字")
return
}
if presetVoices.count >= 5 {
onShowMessage?("最多保存5个语音")
return
}
if presetVoices.contains(text) {
onShowMessage?("该文案已在列表中")
return
}
presetVoices.append(text)
appStore.saveScenicQueuePresetVoices(presetVoices)
onShowMessage?("已保存到本地")
notifyStateChange()
}
func deletePresetVoice(at index: Int) {
guard presetVoices.indices.contains(index) else { return }
let removed = presetVoices.remove(at: index)
appStore.saveScenicQueuePresetVoices(presetVoices)
if ttsLoopAnchorText == removed {
ttsManager.stopCustomTextLoop()
ttsLoopAnchorText = nil
}
notifyStateChange()
}
///
func saveShootQueueQRCode(api: ScenicQueueAPIProtocol) async {
guard let ids = currentScenicAndSpotIdsForSelected() else {
onShowMessage?("请先选择打卡点")
return
}
do {
let data = try await api.shootQueueQrcode(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
guard let url = data.qrcodeUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty else {
onShowMessage?("服务端未返回二维码图片地址")
return
}
try await ScenicQueueQRCodeSaver.saveImage(from: url)
onShowMessage?("保存成功")
} catch {
onShowMessage?((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
}
///
func save(api: ScenicQueueAPIProtocol) async {
highlightedInvalidFieldKeys = []
guard let ids = currentScenicAndSpotIdsForSelected() else {
highlightedInvalidFieldKeys = ["punchSpot"]
settingsTab = .basic
onShowMessage?("请选择打卡点")
notifyStateChange()
return
}
let validation = buildSaveValidation()
guard validation.messages.isEmpty else {
highlightedInvalidFieldKeys = validation.keys
settingsTab = tabForValidationKeys(validation.keys)
onShowMessage?(validation.messages.joined(separator: "\n"))
notifyStateChange()
return
}
let remark = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines)
let request = ScenicQueueSaveSettingRequest(
scenicId: ids.scenicId,
scenicSpotId: ids.scenicSpotId,
photoEstimateMin: shootMinute,
photoEstimateSec: shootSecond,
firstNoticeThresholdPos: firstAheadCount,
firstNoticeSmsEnabled: firstSms ? 1 : 0,
firstNoticeCallEnabled: firstPhone ? 1 : 0,
secondNoticeThresholdPos: secondAheadCount,
secondNoticeSmsEnabled: secondSms ? 1 : 0,
secondNoticeCallEnabled: secondPhone ? 1 : 0,
countdownBroadcastIntervalSec: broadcastIntervalSec,
countdownReadableThresholdSec: countdownThresholdSec,
queueDistanceMeter: queueDistanceMeter,
queueTakeLimit: queueTakeLimit,
missCallRequeueOffset: missCallRequeueOffset,
showStartShootButton: showStartShootingButton ? 1 : 0,
autoCallNextCount: autoCallAheadCount,
businessStartTime: businessStartTime,
businessEndTime: businessEndTime,
voiceBroadcasts: presetVoices.enumerated().map { ScenicQueueVoiceBroadcastItem(content: $0.element, sortOrder: $0.offset + 1) },
status: businessOpen ? 1 : 0,
remark: remark.isEmpty ? nil : remark
)
do {
try await api.saveSetting(request)
appStore.scenicQueuePunchSpotId = String(ids.scenicSpotId)
appStore.scenicQueuePunchSpotName = selectedPunchSpotLabel
appStore.scenicQueueRemark = remark
appStore.saveScenicQueuePresetVoices(presetVoices)
appStore.saveScenicQueueSettingsSnapshot(currentSnapshot())
_ = try? await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
onShowMessage?("保存成功")
onNavigateBack?()
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func fetchQueueSettingAfterPunchSpotSelected(_ spotId: String, api: ScenicQueueAPIProtocol) async {
guard let scenicId = Int64("\(appStore.currentScenicId)"),
let scenicSpotId = Int64(spotId),
scenicId > 0,
scenicSpotId > 0 else { return }
punchSpotSettingRequestGeneration += 1
let generation = punchSpotSettingRequestGeneration
do {
let data = try await api.setting(scenicId: scenicId, scenicSpotId: scenicSpotId)
guard generation == punchSpotSettingRequestGeneration,
selectedPunchSpotId == spotId,
data.exists,
let setting = data.setting else { return }
applyServerSettingToForm(setting)
} catch {
// Keep local form values if remote setting cannot be loaded.
}
}
private func applyServerSettingToForm(_ setting: ScenicQueueSettingItem) {
let fallback = ScenicQueueSettingsSnapshot(
showStartShootingButton: showStartShootingButton,
autoCallAheadCount: autoCallAheadCount,
quickCallButtonEnabled: quickCallButtonEnabled,
prepareCallButtonEnabled: prepareCallButtonEnabled
)
let snapshot = setting.toSnapshot(fallback: fallback)
apply(snapshot)
customTTSText = String((setting.remark ?? "").prefix(Limits.remarkMaxLen))
if let voices = setting.voiceBroadcasts {
presetVoices = voices
.sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) }
.compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed }
.prefix(5)
.map { $0 }
}
if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed {
selectedPunchSpotLabel = name
}
highlightedInvalidFieldKeys = []
notifyStateChange()
}
private func apply(_ snapshot: ScenicQueueSettingsSnapshot) {
shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute)
shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond)
firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead)
firstSms = snapshot.firstSms
firstPhone = snapshot.firstPhone
secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead)
secondSms = snapshot.secondSms
secondPhone = snapshot.secondPhone
broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval)
countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold)
queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance)
queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit)
missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset)
showStartShootingButton = snapshot.showStartShootingButton
autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount)
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
businessOpen = snapshot.businessOpen
businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00")
businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00")
}
private func currentSnapshot() -> ScenicQueueSettingsSnapshot {
ScenicQueueSettingsSnapshot(
shootMinute: shootMinute,
shootSecond: shootSecond,
firstAheadCount: firstAheadCount,
firstSms: firstSms,
firstPhone: firstPhone,
secondAheadCount: secondAheadCount,
secondSms: secondSms,
secondPhone: secondPhone,
broadcastIntervalSec: broadcastIntervalSec,
countdownThresholdSec: countdownThresholdSec,
queueDistanceMeter: queueDistanceMeter,
queueTakeLimit: queueTakeLimit,
missCallRequeueOffset: missCallRequeueOffset,
showStartShootingButton: showStartShootingButton,
autoCallAheadCount: autoCallAheadCount,
quickCallButtonEnabled: quickCallButtonEnabled,
prepareCallButtonEnabled: prepareCallButtonEnabled,
businessOpen: businessOpen,
businessStartTime: businessStartTime,
businessEndTime: businessEndTime
)
}
private func buildSaveValidation() -> (messages: [String], keys: Set<String>) {
var messages: [String] = []
var keys: Set<String> = []
if !Limits.shootMinute.contains(shootMinute) {
messages.append("拍摄时长:分钟须在 030且按 1 分钟递增")
keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue)
}
if !Limits.shootSecond.contains(shootSecond) {
messages.append("拍摄时长:秒须在 059")
keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue)
}
if shootMinute == 0 && shootSecond == 0 {
messages.append("拍摄时长须大于 0")
keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue)
keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue)
}
if !Limits.broadcastInterval.contains(broadcastIntervalSec) {
messages.append("播报间隔时间须在 4060 秒")
keys.insert(ScenicQueueNumericFieldKey.broadcastIntervalSec.rawValue)
}
if !Limits.readableThreshold.contains(countdownThresholdSec) {
messages.append("进入读秒倒计时阈值须在 1030 秒")
keys.insert(ScenicQueueNumericFieldKey.countdownReadableThresholdSec.rawValue)
}
return (messages, keys)
}
private func tabForValidationKeys(_ keys: Set<String>) -> ScenicQueueSettingsTab {
if keys.contains("punchSpot")
|| keys.contains(ScenicQueueNumericFieldKey.queueDistanceMeter.rawValue)
|| keys.contains(ScenicQueueNumericFieldKey.shootMinute.rawValue)
|| keys.contains(ScenicQueueNumericFieldKey.shootSecond.rawValue)
|| keys.contains(ScenicQueueNumericFieldKey.queueTakeLimit.rawValue)
|| keys.contains(ScenicQueueNumericFieldKey.missCallRequeueOffset.rawValue) {
return .basic
}
if keys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue)
|| keys.contains(ScenicQueueNumericFieldKey.secondNoticeAhead.rawValue) {
return .notify
}
return .voice
}
private func currentScenicAndSpotIdsForSelected() -> (scenicId: Int64, scenicSpotId: Int64)? {
guard let scenicId = Int64("\(appStore.currentScenicId)"), scenicId > 0,
let spotIdText = selectedPunchSpotId?.trimmingCharacters(in: .whitespacesAndNewlines),
let scenicSpotId = Int64(spotIdText), scenicSpotId > 0 else { return nil }
return (scenicId, scenicSpotId)
}
private static func normalizedTime(_ raw: String, fallback: String) -> String {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
for format in ["HH:mm", "HH:mm:ss"] {
formatter.dateFormat = format
if let date = formatter.date(from: text) {
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
}
return fallback
}
private func notifyStateChange() {
onStateChange?()
}
}
private extension Int {
func clamped(to range: ClosedRange<Int>) -> Int {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
private extension String {
var nonEmptyTrimmed: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,682 @@
//
// ScenicQueueViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `ScenicQueueViewModel`
final class ScenicQueueViewModel {
private enum Constants {
static let pageSize = 10
static let typeCurrent = 1
static let typeFinished = 2
static let prepareCallLoopGapNanos: UInt64 = 800_000_000
}
private(set) var summary: ScenicQueueSummary = .empty
private(set) var currentQueue: [ScenicQueueTicket] = []
private(set) var skippedQueue: [ScenicQueueSkippedTicket] = []
private(set) var currentRefreshing = false
private(set) var skippedRefreshing = false
private(set) var currentLoadingMore = false
private(set) var skippedLoadingMore = false
private(set) var currentCanLoadMore = false
private(set) var skippedCanLoadMore = false
private(set) var queueGatePassed = false
private(set) var selectedPunchSpotLine = "--"
private(set) var shootingTimedQueueNo: String?
private(set) var shootingRemainSeconds = 0
private(set) var showStartShootingButton = true
private(set) var quickCallButtonEnabled = false
private(set) var prepareCallButtonEnabled = false
private(set) var prepareCallPlaying = false
private(set) var pendingSkip: ScenicQueuePendingSkip?
private(set) var pendingCall: ScenicQueuePendingCall?
private(set) var pendingFinish: ScenicQueuePendingFinish?
private(set) var pendingMark: ScenicQueuePendingMark?
private(set) var pendingRequeue: ScenicQueuePendingRequeue?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onRequireSettings: (() -> Void)?
var onNavigateSettings: (() -> Void)?
var onNavigateBack: (() -> Void)?
private let appStore: AppStore
private let ttsManager: ScenicQueueTTSManaging
private let socketClient: ScenicQueueSocketClient
private var currentListTotal = 0
private var skippedListTotal = 0
private var currentLastLoadedPage = 0
private var skippedLastLoadedPage = 0
private var pendingRemoteCalledRecordIds: Set<Int64> = []
private var handledRemoteCallEventIds: Set<String> = []
private var shootingTask: Task<Void, Never>?
private var prepareCallTask: Task<Void, Never>?
init(
appStore: AppStore = .shared,
ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared,
socketClient: ScenicQueueSocketClient = ScenicQueueSocketClient()
) {
self.appStore = appStore
self.ttsManager = ttsManager
self.socketClient = socketClient
self.socketClient.onMessage = { [weak self] message in
self?.handleSocketMessage(message)
}
refreshSelectedPunchSpotLine()
refreshQueueGateLocally()
}
deinit {
stopQueueStatsPolling()
shootingTask?.cancel()
prepareCallTask?.cancel()
ttsManager.stopQueueCallLoop()
ttsManager.stopCustomTextLoop()
}
/// gate
func refreshQueueGate(api: ScenicQueueAPIProtocol) async {
refreshSelectedPunchSpotLine()
refreshQueueGateLocally()
if queueGatePassed {
await syncQueueSettingFromServerIfMatchesLocal(api: api)
await refreshQueueStats(api: api)
await refreshCurrentQueue(api: api, reset: true)
await refreshSkippedQueue(api: api, reset: true)
await ttsManager.prepareQueueTTSEngine(api: api as? ScenicQueueAPI)
} else {
resetQueues()
onRequireSettings?()
}
}
/// WebSocket
func startQueueStatsPolling(api: ScenicQueueAPIProtocol) {
guard queueGatePassed,
let scenicId = Int64("\(appStore.currentScenicId)"),
let scenicSpotId = Int64(appStore.scenicQueuePunchSpotId),
scenicId > 0,
scenicSpotId > 0 else { return }
Task {
do {
let token = try await api.socketToken().socketToken
socketClient.connect(socketToken: token, scenicId: scenicId, scenicSpotId: scenicSpotId)
} catch {
onShowMessage?(error.localizedDescription)
}
}
}
/// WebSocket
func stopQueueStatsPolling() {
socketClient.disconnect()
}
///
func openSettings() {
if shootingTimedQueueNo != nil {
onShowMessage?("请先完成上一位拍摄,才可以进入设置页面。")
return
}
onNavigateSettings?()
}
/// 退
func exitQueueModule() {
onNavigateBack?()
}
///
func refreshCurrentQueue(api: ScenicQueueAPIProtocol, reset: Bool = true) async {
await loadHomeList(api: api, type: Constants.typeCurrent, page: 1, append: false, isRefresh: reset)
}
///
func loadMoreCurrentQueue(api: ScenicQueueAPIProtocol) async {
guard currentCanLoadMore, !currentRefreshing, !currentLoadingMore else { return }
await loadHomeList(api: api, type: Constants.typeCurrent, page: currentLastLoadedPage + 1, append: true, isRefresh: false)
}
///
func refreshSkippedQueue(api: ScenicQueueAPIProtocol, reset: Bool = true) async {
await loadHomeList(api: api, type: Constants.typeFinished, page: 1, append: false, isRefresh: reset)
}
///
func loadMoreSkippedQueue(api: ScenicQueueAPIProtocol) async {
guard skippedCanLoadMore, !skippedRefreshing, !skippedLoadingMore else { return }
await loadHomeList(api: api, type: Constants.typeFinished, page: skippedLastLoadedPage + 1, append: true, isRefresh: false)
}
///
func refreshQueueStats(api: ScenicQueueAPIProtocol) async {
guard let ids = currentScenicAndSpotIds() else { return }
do {
let data = try await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
let avg = data.avgWaitMin.isFinite ? max(0, Int(data.avgWaitMin.rounded())) : 0
summary = ScenicQueueSummary(peopleInQueue: max(0, data.queueCount), avgWaitMinutes: avg)
notifyStateChange()
} catch {
// Android ignores stats errors silently.
}
}
///
func onTicketMarkClick(recordId: Int64, queueNo: String, uid: Int64) {
guard recordId > 0, uid > 0, !queueNo.isEmpty,
let ticket = currentQueue.first(where: { $0.recordId == recordId }) else { return }
pendingMark = ScenicQueuePendingMark(
uid: uid,
queueNo: queueNo,
phoneMasked: ticket.phoneMasked,
markAsFreelancePhotog: ticket.markAsFreelancePhotog
)
notifyStateChange()
}
func dismissMarkDialog() {
pendingMark = nil
notifyStateChange()
}
///
func confirmUserMark(api: ScenicQueueAPIProtocol, markAsFreelancePhotog: Bool, queueBanDays: Int) async {
guard let pendingMark else { return }
guard appStore.currentScenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
let request = ScenicQueueUserMarkRequest(
uid: pendingMark.uid,
scenicId: Int64(appStore.currentScenicId),
markAsFreelancePhotog: markAsFreelancePhotog ? 1 : 0,
queueBanDays: markAsFreelancePhotog ? min(max(queueBanDays, 0), 999) : nil,
operatorId: Int(appStore.userId)
)
do {
try await api.userMark(request)
dismissMarkDialog()
await refreshCurrentQueue(api: api, reset: true)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func requestSkipTicket(recordId: Int64, queueNo: String) {
guard recordId > 0, !queueNo.isEmpty else { return }
pendingSkip = ScenicQueuePendingSkip(recordId: recordId, queueNo: queueNo)
notifyStateChange()
}
func dismissSkipDialog() {
pendingSkip = nil
notifyStateChange()
}
///
func confirmSkipTicket(api: ScenicQueueAPIProtocol) async {
guard let pending = pendingSkip else { return }
dismissSkipDialog()
ttsManager.stopQueueCallLoop()
do {
_ = try await api.pass(recordId: pending.recordId)
clearShootingSession(for: pending.queueNo)
removeTicketFromCurrentList(recordId: pending.recordId)
await refreshQueueStats(api: api)
await refreshSkippedQueue(api: api, reset: true)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func requestFinishConfirm(recordId: Int64, queueNo: String) {
guard recordId > 0, !queueNo.isEmpty else { return }
if let active = shootingTimedQueueNo, active != queueNo {
onShowMessage?("请先完成上一位拍摄。")
return
}
pendingFinish = ScenicQueuePendingFinish(recordId: recordId, queueNo: queueNo)
notifyStateChange()
}
func dismissFinishDialog() {
pendingFinish = nil
notifyStateChange()
}
///
func confirmFinishTicket(api: ScenicQueueAPIProtocol) async {
guard let pending = pendingFinish else { return }
dismissFinishDialog()
await finishTicket(api: api, pending: pending)
}
func requestRequeueConfirm(recordId: Int64, queueNo: String) {
guard recordId > 0, !queueNo.isEmpty else { return }
pendingRequeue = ScenicQueuePendingRequeue(recordId: recordId, queueNo: queueNo)
notifyStateChange()
}
func dismissRequeueDialog() {
pendingRequeue = nil
notifyStateChange()
}
///
func confirmRequeueTicket(api: ScenicQueueAPIProtocol) async {
guard let pending = pendingRequeue else { return }
dismissRequeueDialog()
guard let operatorId = Int(appStore.userId), operatorId > 0 else {
onShowMessage?("请先登录")
return
}
do {
try await api.requeueInsertBefore(recordId: pending.recordId, operatorId: operatorId)
await refreshQueueStats(api: api)
await refreshCurrentQueue(api: api, reset: true)
await refreshSkippedQueue(api: api, reset: true)
} catch {
onShowMessage?(error.localizedDescription)
}
}
func requestCallConfirm(recordId: Int64, queueNo: String, isRecall: Bool = false) {
if shootingTimedQueueNo != nil {
onShowMessage?("请先完成上一位拍摄,再进行叫号。")
return
}
guard recordId > 0, !queueNo.isEmpty else { return }
pendingCall = ScenicQueuePendingCall(recordId: recordId, queueNo: queueNo, isRecall: isRecall)
notifyStateChange()
}
func dismissCallConfirmDialog() {
pendingCall = nil
notifyStateChange()
}
///
func confirmCallTicket(api: ScenicQueueAPIProtocol) async {
guard let pending = pendingCall else { return }
dismissCallConfirmDialog()
stopPrepareCallPlayback()
await callTicketDirectly(api: api, recordId: pending.recordId, queueNo: pending.queueNo)
}
///
func quickCallFirstTicket(api: ScenicQueueAPIProtocol) async {
if shootingTimedQueueNo != nil {
onShowMessage?("请先完成上一位拍摄,再进行叫号。")
return
}
guard let first = currentQueue.first else {
onShowMessage?("暂无可叫号的排队用户")
return
}
stopPrepareCallPlayback()
await callTicketDirectly(api: api, recordId: first.recordId, queueNo: first.queueNo)
}
///
func togglePrepareCallPlayback() {
if prepareCallPlaying {
stopPrepareCallPlayback()
return
}
let queueNos = prepareCallQueueNos()
guard !queueNos.isEmpty else {
onShowMessage?("暂无可播报的排队用户")
return
}
prepareCallTask?.cancel()
prepareCallPlaying = true
notifyStateChange()
prepareCallTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
let latest = self.prepareCallQueueNos()
if latest.isEmpty { break }
_ = await self.ttsManager.speakPlainTextOnce(self.prepareCallAnnouncementText(latest))
try? await Task.sleep(nanoseconds: Constants.prepareCallLoopGapNanos)
}
self.prepareCallPlaying = false
self.prepareCallTask = nil
self.notifyStateChange()
}
}
///
func startShooting(api: ScenicQueueAPIProtocol, recordId: Int64, queueNo: String) {
guard recordId > 0, !queueNo.isEmpty, shootingTimedQueueNo == nil else { return }
stopPrepareCallPlayback()
ttsManager.stopQueueCallLoop()
let timing = resolveShootingTiming()
guard timing.totalSeconds > 0 else {
onShowMessage?("请先在排队配置中设置拍摄时长")
return
}
onShowMessage?("开始计时拍摄")
shootingTimedQueueNo = queueNo
shootingRemainSeconds = timing.totalSeconds
notifyStateChange()
shootingTask?.cancel()
shootingTask = Task { [weak self] in
guard let self else { return }
await self.speakFollowingTickets(after: queueNo)
let start = Date()
while !Task.isCancelled {
let elapsed = Int(Date().timeIntervalSince(start).rounded(.down))
let remain = max(0, timing.totalSeconds - elapsed)
self.shootingRemainSeconds = remain
self.notifyStateChange()
if remain <= 0 { break }
if remain == timing.readableThresholdSeconds {
_ = await self.ttsManager.speakPlainTextOnce("当前剩余拍摄时间还剩\(remain)")
} else if remain < timing.readableThresholdSeconds && remain > 0 {
_ = await self.ttsManager.speakPlainTextOnce("\(remain)")
} else if timing.intervalSeconds > 0 && elapsed > 0 && elapsed % timing.intervalSeconds == 0 {
_ = await self.ttsManager.speakPlainTextOnce(self.shootingRemainAnnouncementText(queueNo: queueNo, remainSeconds: remain))
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
self.ttsManager.interruptShootingAnnouncement()
self.shootingTimedQueueNo = nil
self.shootingRemainSeconds = 0
self.shootingTask = nil
self.notifyStateChange()
if !Task.isCancelled {
await self.finishTicket(api: api, pending: ScenicQueuePendingFinish(recordId: recordId, queueNo: queueNo))
}
}
}
private func refreshSelectedPunchSpotLine() {
selectedPunchSpotLine = appStore.hasScenicQueuePunchSpotSaved()
? appStore.scenicQueuePunchSpotName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "--"
: "--"
refreshShootingCallConfig()
}
private func refreshQueueGateLocally() {
queueGatePassed = appStore.hasScenicQueuePunchSpotSaved()
}
private func refreshShootingCallConfig() {
let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot()
showStartShootingButton = snapshot.showStartShootingButton
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
}
private func syncQueueSettingFromServerIfMatchesLocal(api: ScenicQueueAPIProtocol) async {
guard let ids = currentScenicAndSpotIds() else { return }
do {
let data = try await api.setting(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId)
guard data.exists, let setting = data.setting,
setting.scenicId == nil || setting.scenicId == ids.scenicId,
setting.scenicSpotId == nil || setting.scenicSpotId == ids.scenicSpotId else { return }
let local = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot()
appStore.saveScenicQueueSettingsSnapshot(setting.toSnapshot(fallback: local))
appStore.scenicQueueRemark = setting.remark ?? ""
if let voices = setting.voiceBroadcasts {
appStore.saveScenicQueuePresetVoices(
voices
.sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) }
.compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty }
)
}
if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty {
appStore.scenicQueuePunchSpotName = name
selectedPunchSpotLine = name
}
refreshShootingCallConfig()
notifyStateChange()
} catch {
// Android keeps local snapshot if sync fails.
}
}
private func loadHomeList(api: ScenicQueueAPIProtocol, type: Int, page: Int, append: Bool, isRefresh: Bool) async {
guard let ids = currentScenicAndSpotIds() else { return }
let isCurrent = type == Constants.typeCurrent
if isRefresh {
if isCurrent {
guard !currentRefreshing else { return }
currentRefreshing = true
} else {
guard !skippedRefreshing else { return }
skippedRefreshing = true
}
} else {
if isCurrent {
guard !currentLoadingMore else { return }
currentLoadingMore = true
} else {
guard !skippedLoadingMore else { return }
skippedLoadingMore = true
}
}
notifyStateChange()
defer {
if isRefresh {
if isCurrent { currentRefreshing = false } else { skippedRefreshing = false }
} else {
if isCurrent { currentLoadingMore = false } else { skippedLoadingMore = false }
}
notifyStateChange()
}
do {
let data = try await api.home(
scenicId: ids.scenicId,
scenicSpotId: ids.scenicSpotId,
type: type,
page: page,
pageSize: Constants.pageSize
)
guard let block = data.list else { return }
if isCurrent {
let mapped = block.list.map { $0.toCurrentTicket() }
currentQueue = append ? appendUnique(existing: currentQueue, incoming: mapped, key: \.recordId) : mapped
currentListTotal = max(0, block.total)
currentLastLoadedPage = max(1, block.page)
currentCanLoadMore = currentQueue.count < currentListTotal && !block.list.isEmpty
consumePendingRemoteCalledTickets()
} else {
let mapped = block.list.map { $0.toSkippedTicket() }
skippedQueue = append ? appendUnique(existing: skippedQueue, incoming: mapped, key: \.recordId) : mapped
skippedListTotal = max(0, block.total)
skippedLastLoadedPage = max(1, block.page)
skippedCanLoadMore = skippedQueue.count < skippedListTotal && !block.list.isEmpty
}
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func handleSocketMessage(_ message: ScenicQueueSocketMessage) {
guard let params = message.data?.params,
let changedSpotId = params.scenicSpotId,
let currentSpotId = Int64(appStore.scenicQueuePunchSpotId),
changedSpotId == currentSpotId else { return }
switch message.data?.action {
case ScenicQueueSocketAction.queueUpdated.rawValue:
NotificationCenter.default.post(name: NotificationName.scenicQueueDidUpdate, object: nil)
case ScenicQueueSocketAction.ticketCalled.rawValue:
handleRemoteTicketCalled(params)
NotificationCenter.default.post(name: NotificationName.scenicQueueDidUpdate, object: nil)
default:
break
}
}
private func handleRemoteTicketCalled(_ params: ScenicQueueSocketParams) {
guard let recordId = params.recordId, recordId > 0 else { return }
if let myUid = Int64(appStore.userId), let operatorUid = params.operatorUid, myUid == operatorUid {
return
}
let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !eventId.isEmpty, handledRemoteCallEventIds.contains(eventId) {
return
}
if !eventId.isEmpty {
handledRemoteCallEventIds.insert(eventId)
}
pendingRemoteCalledRecordIds.insert(recordId)
}
private func consumePendingRemoteCalledTickets() {
guard let called = currentQueue.first(where: { pendingRemoteCalledRecordIds.contains($0.recordId) && $0.status == .called }) else { return }
pendingRemoteCalledRecordIds.remove(called.recordId)
stopPrepareCallPlayback()
ttsManager.startQueueCallLoop(queueNo: called.queueNo)
}
private func callTicketDirectly(api: ScenicQueueAPIProtocol, recordId: Int64, queueNo: String) async {
do {
let data = try await api.call(recordId: recordId)
applyCallSuccessLocally(recordId: recordId, queueNo: queueNo, statusText: data.statusText)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func applyCallSuccessLocally(recordId: Int64, queueNo: String, statusText: String) {
let shouldShowStartButton = currentShowStartShootingButton()
if shouldShowStartButton {
ttsManager.startQueueCallLoop(queueNo: queueNo)
}
currentQueue = currentQueue.map { ticket in
ticket.recordId == recordId || ticket.queueNo == queueNo
? ticket.markedCalled(statusText: statusText)
: ticket
}
notifyStateChange()
}
private func finishTicket(api: ScenicQueueAPIProtocol, pending: ScenicQueuePendingFinish) async {
do {
let data = try await api.finish(recordId: pending.recordId)
ttsManager.stopQueueCallLoop()
clearShootingSession(for: pending.queueNo)
removeTicketFromCurrentList(recordId: pending.recordId)
if !data.statusText.isEmpty {
onShowMessage?(data.statusText)
}
await refreshQueueStats(api: api)
await refreshSkippedQueue(api: api, reset: true)
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func removeTicketFromCurrentList(recordId: Int64) {
currentQueue.removeAll { $0.recordId == recordId }
currentListTotal = max(0, currentListTotal - 1)
currentCanLoadMore = currentListTotal > 0 && currentQueue.count < currentListTotal
notifyStateChange()
}
private func clearShootingSession(for queueNo: String) {
guard shootingTimedQueueNo == queueNo else { return }
shootingTask?.cancel()
shootingTask = nil
ttsManager.interruptShootingAnnouncement()
shootingTimedQueueNo = nil
shootingRemainSeconds = 0
notifyStateChange()
}
private func stopPrepareCallPlayback() {
prepareCallTask?.cancel()
prepareCallTask = nil
ttsManager.interruptShootingAnnouncement()
prepareCallPlaying = false
notifyStateChange()
}
private func prepareCallQueueNos() -> [String] {
let active = shootingTimedQueueNo?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return currentQueue
.map { $0.queueNo.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && (active.isEmpty || $0 != active) }
.prefix(3)
.map { $0 }
}
private func prepareCallAnnouncementText(_ queueNos: [String]) -> String {
let joined = queueNos.map(AliyunNLSTTSManager.formatQueueNoForTTS).joined(separator: "")
return "\(joined),请提前做好拍摄准备。"
}
private func resolveShootingTiming() -> (totalSeconds: Int, intervalSeconds: Int, readableThresholdSeconds: Int) {
let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot()
let total = max(0, snapshot.shootMinute * 60 + snapshot.shootSecond)
let interval = (40...60).contains(snapshot.broadcastIntervalSec) ? snapshot.broadcastIntervalSec : 50
let readable = (10...30).contains(snapshot.countdownThresholdSec) ? snapshot.countdownThresholdSec : 15
return (total, interval, readable)
}
private func currentShowStartShootingButton() -> Bool {
refreshShootingCallConfig()
return showStartShootingButton
}
private func speakFollowingTickets(after queueNo: String) async {
let count = (appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot()).autoCallAheadCount.clamped(to: 0...5)
guard count > 0, let index = currentQueue.firstIndex(where: { $0.queueNo == queueNo }) else { return }
let nextNos = currentQueue.dropFirst(index + 1).prefix(count).map(\.queueNo).filter { !$0.isEmpty }
guard !nextNos.isEmpty else { return }
_ = await ttsManager.speakPlainTextOnce("\(nextNos.map(AliyunNLSTTSManager.formatQueueNoForTTS).joined(separator: ""))提前做好拍摄准备。")
}
private func shootingRemainAnnouncementText(queueNo: String, remainSeconds: Int) -> String {
"\(AliyunNLSTTSManager.formatQueueNoForTTS(queueNo)),当前剩余拍摄时间还剩\(remainSeconds)"
}
private func currentScenicAndSpotIds() -> (scenicId: Int64, scenicSpotId: Int64)? {
let scenicId = Int64(appStore.currentScenicId)
guard scenicId > 0,
let scenicSpotId = Int64(appStore.scenicQueuePunchSpotId),
scenicSpotId > 0 else { return nil }
return (scenicId, scenicSpotId)
}
private func resetQueues() {
summary = .empty
currentQueue = []
skippedQueue = []
currentListTotal = 0
skippedListTotal = 0
currentLastLoadedPage = 0
skippedLastLoadedPage = 0
currentCanLoadMore = false
skippedCanLoadMore = false
notifyStateChange()
}
private func appendUnique<Item, Key: Hashable>(existing: [Item], incoming: [Item], key: KeyPath<Item, Key>) -> [Item] {
var seen = Set(existing.map { $0[keyPath: key] })
return existing + incoming.filter { seen.insert($0[keyPath: key]).inserted }
}
private func notifyStateChange() {
onStateChange?()
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}
private extension Int {
func clamped(to range: ClosedRange<Int>) -> Int {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}