// // 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 = [] private var handledRemoteCallEventIds: Set = [] private var shootingTask: Task? private var prepareCallTask: Task? 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(existing: [Item], incoming: [Item], key: KeyPath) -> [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 { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } }