将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
290 lines
9.3 KiB
Swift
290 lines
9.3 KiB
Swift
//
|
||
// ScenicQueueRuntime.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import AVFoundation
|
||
import Observation
|
||
import SwiftUI
|
||
import UIKit
|
||
|
||
@MainActor
|
||
@Observable
|
||
/// 排队运行时,页面外按开关短轮询当前打卡点并语音播报队列变化。
|
||
final class ScenicQueueRuntime {
|
||
private(set) var isMonitoring = false
|
||
private(set) var lastPollText = "--"
|
||
private(set) var lastSpokenText = ""
|
||
private(set) var lastQueueCount = 0
|
||
private(set) var lastError: String?
|
||
|
||
@ObservationIgnored private let speaker = ScenicQueueSpeechService()
|
||
@ObservationIgnored private weak var api: (any ScenicQueueServing)?
|
||
@ObservationIgnored private var userId: String?
|
||
@ObservationIgnored private var scenicId: Int?
|
||
@ObservationIgnored private var scenePhase: ScenePhase = .active
|
||
@ObservationIgnored private var pollTask: Task<Void, Never>?
|
||
@ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
|
||
@ObservationIgnored private var announcementState = ScenicQueueAnnouncementState()
|
||
@ObservationIgnored private var lastScenicId: Int?
|
||
@ObservationIgnored private var lastSpotId: Int?
|
||
@ObservationIgnored private var suspendedByQueueScreen = false
|
||
|
||
/// 语音播报是否开启。
|
||
var voiceEnabled: Bool {
|
||
get {
|
||
if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.ttsEnabledKey) == nil { return true }
|
||
return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.ttsEnabledKey)
|
||
}
|
||
set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.ttsEnabledKey) }
|
||
}
|
||
|
||
/// 后台短时轮询是否开启。
|
||
var backgroundPollingEnabled: Bool {
|
||
get {
|
||
if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) == nil { return false }
|
||
return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey)
|
||
}
|
||
set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) }
|
||
}
|
||
|
||
/// 更新运行时上下文。
|
||
func update(
|
||
api: any ScenicQueueServing,
|
||
userId: String?,
|
||
scenicId: Int?,
|
||
scenePhase: ScenePhase
|
||
) {
|
||
self.api = api
|
||
self.userId = userId
|
||
self.scenicId = scenicId
|
||
self.scenePhase = scenePhase
|
||
|
||
guard !suspendedByQueueScreen else {
|
||
stop()
|
||
return
|
||
}
|
||
|
||
guard let scenicId else {
|
||
stop()
|
||
resetSnapshot()
|
||
return
|
||
}
|
||
|
||
let spotId = selectedSpotId()
|
||
guard spotId > 0 else {
|
||
stop()
|
||
lastError = "排队监听未启动:请先在排队设置里选择打卡点"
|
||
return
|
||
}
|
||
|
||
if lastScenicId != scenicId || lastSpotId != spotId {
|
||
resetSnapshot()
|
||
lastScenicId = scenicId
|
||
lastSpotId = spotId
|
||
}
|
||
|
||
if scenePhase == .background, !backgroundPollingEnabled {
|
||
stop()
|
||
return
|
||
}
|
||
|
||
startIfNeeded()
|
||
}
|
||
|
||
/// 页面自身接管实时监听时暂停运行时轮询。
|
||
func setSuspendedByQueueScreen(_ suspended: Bool) {
|
||
guard suspendedByQueueScreen != suspended else { return }
|
||
suspendedByQueueScreen = suspended
|
||
if suspended {
|
||
stop()
|
||
} else if let api {
|
||
update(api: api, userId: userId, scenicId: scenicId, scenePhase: scenePhase)
|
||
}
|
||
}
|
||
|
||
/// 停止运行时轮询和播报。
|
||
func stop() {
|
||
pollTask?.cancel()
|
||
pollTask = nil
|
||
isMonitoring = false
|
||
speaker.stop()
|
||
endBackgroundTask()
|
||
}
|
||
|
||
/// 立即轮询一次。
|
||
func pollNow() {
|
||
guard pollTask != nil else { return }
|
||
Task { await pollOnce() }
|
||
}
|
||
|
||
private func startIfNeeded() {
|
||
guard pollTask == nil else { return }
|
||
isMonitoring = true
|
||
if scenePhase == .background {
|
||
beginBackgroundTask()
|
||
}
|
||
pollTask = Task { [weak self] in
|
||
await self?.runLoop()
|
||
}
|
||
}
|
||
|
||
private func runLoop() async {
|
||
while !Task.isCancelled {
|
||
await pollOnce()
|
||
let seconds = pollIntervalSeconds()
|
||
try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
|
||
}
|
||
}
|
||
|
||
private func pollOnce() async {
|
||
guard let api, let scenicId else { return }
|
||
let spotId = selectedSpotId()
|
||
guard spotId > 0 else { return }
|
||
|
||
if scenePhase == .background {
|
||
beginBackgroundTask()
|
||
}
|
||
|
||
do {
|
||
if lastScenicId != scenicId || lastSpotId != spotId {
|
||
resetSnapshot()
|
||
lastScenicId = scenicId
|
||
lastSpotId = spotId
|
||
}
|
||
async let statsData = api.scenicQueueStats(scenicId: scenicId, scenicSpotId: spotId)
|
||
async let homeData = api.scenicQueueHome(
|
||
scenicId: scenicId,
|
||
scenicSpotId: spotId,
|
||
type: QueueListType.queueing.rawValue,
|
||
page: 1,
|
||
pageSize: 20
|
||
)
|
||
let (stats, home) = try await (statsData, homeData)
|
||
handle(stats: stats, tickets: home.list?.list ?? [])
|
||
lastError = nil
|
||
} catch {
|
||
lastError = error.localizedDescription
|
||
}
|
||
}
|
||
|
||
private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "HH:mm:ss"
|
||
lastPollText = formatter.string(from: Date())
|
||
lastQueueCount = stats.queueCount
|
||
|
||
guard voiceEnabled else { return }
|
||
let customText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: lastSpotId)
|
||
if let announcement = announcementState.nextAnnouncement(stats: stats, tickets: tickets, customCallText: customText) {
|
||
lastSpokenText = announcement.text
|
||
speaker.speak(announcement.text)
|
||
}
|
||
}
|
||
|
||
private func pollIntervalSeconds() -> UInt64 {
|
||
switch scenePhase {
|
||
case .active:
|
||
return 12
|
||
case .inactive:
|
||
return 20
|
||
case .background:
|
||
return 30
|
||
@unknown default:
|
||
return 20
|
||
}
|
||
}
|
||
|
||
private func selectedSpotId() -> Int {
|
||
ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
|
||
?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey)
|
||
}
|
||
|
||
private func resetSnapshot() {
|
||
announcementState.reset()
|
||
lastQueueCount = 0
|
||
lastPollText = "--"
|
||
lastSpokenText = ""
|
||
}
|
||
|
||
private func beginBackgroundTask() {
|
||
guard backgroundTaskId == .invalid else { return }
|
||
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in
|
||
Task { @MainActor in
|
||
self?.endBackgroundTask()
|
||
self?.stop()
|
||
}
|
||
}
|
||
}
|
||
|
||
private func endBackgroundTask() {
|
||
guard backgroundTaskId != .invalid else { return }
|
||
UIApplication.shared.endBackgroundTask(backgroundTaskId)
|
||
backgroundTaskId = .invalid
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
/// 排队语音播报服务。
|
||
final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate {
|
||
private let synthesizer = AVSpeechSynthesizer()
|
||
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
|
||
|
||
override init() {
|
||
super.init()
|
||
synthesizer.delegate = self
|
||
}
|
||
|
||
/// 播放语音文本。
|
||
func speak(_ text: String) {
|
||
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !normalized.isEmpty else { return }
|
||
enqueue(normalized, continuation: nil, replacePending: true)
|
||
}
|
||
|
||
/// 播放语音并等待结束,供测试和设置页试听使用。
|
||
func speakAndWait(_ text: String) async {
|
||
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !normalized.isEmpty else { return }
|
||
await withCheckedContinuation { continuation in
|
||
enqueue(normalized, continuation: continuation, replacePending: false)
|
||
}
|
||
}
|
||
|
||
/// 停止语音播报。
|
||
func stop() {
|
||
synthesizer.stopSpeaking(at: .immediate)
|
||
resumePending()
|
||
}
|
||
|
||
private func enqueue(_ normalized: String, continuation: CheckedContinuation<Void, Never>?, replacePending: Bool) {
|
||
if replacePending {
|
||
synthesizer.stopSpeaking(at: .immediate)
|
||
resumePending()
|
||
}
|
||
if let continuation {
|
||
pendingContinuations.append(continuation)
|
||
}
|
||
let utterance = AVSpeechUtterance(string: normalized)
|
||
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
|
||
utterance.rate = 0.48
|
||
synthesizer.speak(utterance)
|
||
}
|
||
|
||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||
Task { @MainActor in self.resumePending() }
|
||
}
|
||
|
||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||
Task { @MainActor in self.resumePending() }
|
||
}
|
||
|
||
private func resumePending() {
|
||
let continuations = pendingContinuations
|
||
pendingContinuations.removeAll()
|
||
continuations.forEach { $0.resume() }
|
||
}
|
||
}
|