新增订单长尾流程,并迁移排队、消息、结算与审核模块

将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent 311a70d610
commit c39c3d3c75
63 changed files with 9823 additions and 58 deletions

View File

@ -0,0 +1,73 @@
//
// ScenicQueueAnnouncementState.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
struct ScenicQueueAnnouncement: Equatable {
enum Kind: Equatable {
case newTickets(count: Int)
case calledTicket(id: Int64)
}
let kind: Kind
let text: String
}
///
struct ScenicQueueAnnouncementState {
private var knownTicketIds = Set<Int64>()
private var calledTicketIds = Set<Int64>()
///
mutating func nextAnnouncement(
stats: ScenicQueueStatsData,
tickets: [ScenicQueueTicket],
customCallText: String?
) -> ScenicQueueAnnouncement? {
let currentIds = Set(tickets.map(\.id))
let newTickets = tickets.filter { !knownTicketIds.contains($0.id) }
knownTicketIds = currentIds
if let ticket = tickets.first(where: { ($0.isCalled == 1 || $0.statusText.contains("已叫号")) && !calledTicketIds.contains($0.id) }) {
calledTicketIds.insert(ticket.id)
return ScenicQueueAnnouncement(kind: .calledTicket(id: ticket.id), text: Self.callText(for: ticket, customText: customCallText))
}
guard !newTickets.isEmpty else { return nil }
let text = "排队提醒,当前新增\(newTickets.count)位游客,在排人数\(stats.queueCount)人。"
return ScenicQueueAnnouncement(kind: .newTickets(count: newTickets.count), text: text)
}
///
mutating func reset() {
knownTicketIds.removeAll()
calledTicketIds.removeAll()
}
///
static func callText(for ticket: ScenicQueueTicket, customText: String?) -> String {
callText(forQueueCode: ticket.queueCode, customText: customText)
}
///
static func callText(forQueueCode queueCode: String, customText: String?) -> String {
let custom = customText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !custom.isEmpty, custom != "A0001" {
return custom.replacingOccurrences(of: "{number}", with: queueCode)
}
let code = queueCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "当前游客" : ttsLabel(for: queueCode)
return "\(code)到拍摄点拍摄。"
}
///
static func ttsLabel(for queueCode: String) -> String {
let trimmed = queueCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "当前游客" }
return trimmed.map(String.init).joined(separator: " ")
}
}

View File

@ -0,0 +1,289 @@
//
// 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() }
}
}

View File

@ -0,0 +1,166 @@
//
// ScenicQueueSocketClient.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
/// WebSocket
struct ScenicQueueSocketMessage: Equatable {
static let queueUpdatedAction = "scenic_spot_queue_updated"
static let ticketCalledAction = "scenic_queue_ticket_called"
let code: Int
let data: ScenicQueueSocketData?
var isScenicQueueEvent: Bool {
let action = data?.action ?? ""
return action == Self.queueUpdatedAction || action == Self.ticketCalledAction
}
}
/// WebSocket data
struct ScenicQueueSocketData: Equatable {
let action: String
let params: ScenicQueueSocketParams?
}
/// WebSocket
struct ScenicQueueSocketParams: Equatable {
let scenicSpotId: Int64?
let recordId: Int64?
let operatorUid: Int64?
let eventId: String?
}
@MainActor
/// WebSocket
final class ScenicQueueSocketClient {
private var webSocketTask: URLSessionWebSocketTask?
private var receiveTask: Task<Void, Never>?
/// WebSocket
func connect(
socketToken: String,
scenicSpotId: Int,
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
) {
let token = socketToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { return }
disconnect(reason: "Reconnect before scenic queue socket connect")
let task = URLSession.shared.webSocketTask(with: APIEnvironment.current.webSocketURL)
webSocketTask = task
task.resume()
receiveTask = Task { [weak self] in
await self?.receiveLoop(task: task, onMessage: onMessage)
}
Task {
for payload in Self.subscriptionPayloads(scenicSpotId: scenicSpotId) {
try? await task.send(.string(payload))
}
}
}
/// WebSocket
func disconnect(reason: String = "Scenic queue page stopped") {
receiveTask?.cancel()
receiveTask = nil
webSocketTask?.cancel(with: .normalClosure, reason: reason.data(using: .utf8))
webSocketTask = nil
}
private func receiveLoop(
task: URLSessionWebSocketTask,
onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void
) async {
while !Task.isCancelled {
do {
let message = try await task.receive()
let text: String?
switch message {
case .string(let value):
text = value
case .data(let data):
text = String(data: data, encoding: .utf8)
@unknown default:
text = nil
}
guard let text, let parsed = Self.parseMessage(text), parsed.isScenicQueueEvent else { continue }
onMessage(parsed)
} catch {
return
}
}
}
/// payloadtype Android
static func subscriptionPayloads(scenicSpotId: Int) -> [String] {
[
#"{"type":306,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#,
#"{"type":307,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#
]
}
/// WebSocket
static func parseMessage(_ raw: String) -> ScenicQueueSocketMessage? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8) else { return nil }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let code = intValue(root["code"]) ?? 0
var socketData: ScenicQueueSocketData?
if let dataObject = root["data"] as? [String: Any] {
let action = stringValue(dataObject["action"])
var params: ScenicQueueSocketParams?
if let paramsObject = dataObject["params"] as? [String: Any] {
params = ScenicQueueSocketParams(
scenicSpotId: int64Value(paramsObject["scenic_spot_id"]),
recordId: int64Value(paramsObject["record_id"]),
operatorUid: int64Value(paramsObject["operator_uid"]),
eventId: stringValue(paramsObject["event_id"]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
)
}
socketData = ScenicQueueSocketData(action: action, params: params)
}
return ScenicQueueSocketMessage(code: code, data: socketData)
}
private static func stringValue(_ value: Any?) -> String {
switch value {
case let value as String:
return value
case let value as NSNumber:
return value.stringValue
case let value?:
return "\(value)"
case nil:
return ""
}
}
private static func intValue(_ value: Any?) -> Int? {
if let value = value as? Int { return value }
if let value = value as? NSNumber { return value.intValue }
if let value = value as? String { return Int(value) }
return nil
}
private static func int64Value(_ value: Any?) -> Int64? {
if let value = value as? Int64 { return value }
if let value = value as? Int { return Int64(value) }
if let value = value as? NSNumber { return value.int64Value }
if let value = value as? String { return Int64(value) }
return nil
}
}
private extension String {
var nilIfEmpty: String? {
isEmpty ? nil : self
}
}

View File

@ -0,0 +1,62 @@
//
// AppFormValidator.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
enum AppFormValidator {
///
static func rangedInteger(_ text: String, name: String, range: ClosedRange<Int>) throws -> Int {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let value = Int(trimmed) else {
throw APIError.networkFailed("\(name)格式不正确")
}
guard range.contains(value) else {
throw APIError.networkFailed("\(name)须在 \(range.lowerBound)\(range.upperBound)")
}
return value
}
///
static func validateQueueNoticeThresholds(first: Int, second: Int) throws {
guard second <= first else {
throw APIError.networkFailed("第二次通知阈值不能大于第一次通知阈值")
}
}
///
static func sanitizedMoneyInput(_ input: String, maxDecimalPlaces: Int = 2) -> String {
var result = ""
var hasDot = false
var decimalCount = 0
for char in input {
if char.isNumber {
if hasDot {
guard decimalCount < maxDecimalPlaces else { continue }
decimalCount += 1
}
result.append(char)
} else if char == ".", !hasDot {
hasDot = true
result.append(char)
}
}
if result.hasPrefix(".") {
result = "0" + result
}
return result
}
///
static func normalizedMoneyForSubmit(_ input: String) -> String {
let sanitized = sanitizedMoneyInput(input, maxDecimalPlaces: 2)
guard !sanitized.isEmpty, sanitized != "." else { return "" }
return sanitized
}
}