683 lines
27 KiB
Swift
683 lines
27 KiB
Swift
//
|
||
// 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)
|
||
} 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)
|
||
}
|
||
}
|