新增景区排队管理功能

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,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
}
}