Files
suixinkan_ios_uikit/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift
汉秋 d99a5b1bf8 Advance UIKit rewrite with AMap integration and core UI modules.
Integrate高德 SDK with simulator-safe build flags, add map views for operating area and punch points, refactor main tabs and key feature screens to UIKit with Diffable lists, and document Swift concurrency defaults in AGENTS.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 15:16:12 +08:00

912 lines
42 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// QueueManagementViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import Photos
import UIKit
@MainActor
/// ViewModel
final class QueueManagementViewModel {
var onChange: (() -> Void)?
var loading = false { didSet { onChange?() } }
var loadingMore = false { didSet { onChange?() } }
var items: [QueueItem] = [] { didSet { onChange?() } }
var scenicSpots: [ScenicSpotItem] = [] { didSet { onChange?() } }
var selectedSpotId: Int? { didSet { onChange?() } }
var selectedListType: QueueListType = .queueing { didSet { onChange?() } }
var queueCount = 0 { didSet { onChange?() } }
var avgWaitMin = 0.0 { didSet { onChange?() } }
var totalCount = 0 { didSet { onChange?() } }
var hasMore = false { didSet { onChange?() } }
var page = 1 { didSet { onChange?() } }
var lastSyncTimeText = "--" { didSet { onChange?() } }
var queueGatePassed = false { didSet { onChange?() } }
var selectedSpotName = "--" { didSet { onChange?() } }
var showStartShootingButton = true { didSet { onChange?() } }
var autoCallAheadCount = 0 { didSet { onChange?() } }
var quickCallButtonEnabled = false { didSet { onChange?() } }
var prepareCallButtonEnabled = false { didSet { onChange?() } }
var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement? { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private let pageSize = 10
private let socketClient = ScenicQueueSocketClient()
private var realtimeSpotId: Int?
private var realtimeScenicId: Int?
private var currentUserId: String?
private var currentScenicId: Int?
private var pendingRemoteCalledRecordIds = Set<Int64>()
private var handledRemoteCallEventIds = Set<String>()
///
var pendingCount: Int {
items.filter { $0.status < 7 && !$0.statusText.contains("完成") }.count
}
///
var avgWaitMinText: String {
avgWaitMin.rounded() == avgWaitMin ? "\(Int(avgWaitMin))" : String(format: "%.1f", avgWaitMin)
}
///
var currentSpotName: String {
scenicSpots.first(where: { $0.id == selectedSpotId })?.name
?? selectedSpotName.nonEmptyOrDefault(queueGatePassed ? "--" : "未设置打卡点")
}
///
func reload(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {
guard let scenicId else {
reset()
return
}
loading = true
errorMessage = nil
defer { loading = false }
currentUserId = userId
currentScenicId = scenicId
scenicSpots = spots
let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
if let savedSpotId, spots.isEmpty || spots.contains(where: { $0.id == savedSpotId }) {
selectedSpotId = savedSpotId
selectedSpotName = ScenicQueueSettingsStore.selectedSpotName(userId: userId, scenicId: scenicId)
.nonEmptyOrDefault(spots.first(where: { $0.id == savedSpotId })?.name ?? "--")
queueGatePassed = true
await syncQueueSettingFromServerIfMatchesLocal(api: api, userId: userId, scenicId: scenicId, spotId: savedSpotId)
refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: savedSpotId)
} else {
selectedSpotId = nil
selectedSpotName = "--"
queueGatePassed = false
clearQueueData()
return
}
do {
try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1)
} catch {
clearQueueData()
errorMessage = error.localizedDescription
}
}
///
func selectListType(_ type: QueueListType, api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
guard selectedListType != type else { return }
selectedListType = type
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
}
///
func reloadCurrentSpot(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
guard let scenicId else { return }
loading = true
errorMessage = nil
defer { loading = false }
do {
try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1)
} catch {
clearQueueData()
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
guard let scenicId, hasMore, !loadingMore else { return }
loadingMore = true
defer { loadingMore = false }
do {
try await loadPage(api: api, scenicId: scenicId, userId: userId, page: page + 1)
} catch {
errorMessage = error.localizedDescription
}
}
///
func callQueue(api: any ScenicQueueServing, id: Int64) async throws {
let data = try await api.scenicQueueCall(id: id)
markQueueCalled(id: id, statusText: data.statusText)
}
///
func passQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws {
_ = try await api.scenicQueuePass(id: id)
items.removeAll { $0.id == id }
queueCount = max(queueCount - 1, 0)
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
}
///
func finishQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws -> ScenicQueueFinishData {
let data = try await api.scenicQueueFinish(id: id)
items.removeAll { $0.id == id }
queueCount = max(queueCount - 1, 0)
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
return data
}
///
func requeue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64, operatorId: Int) async throws {
try await api.scenicQueueRequeueInsertBefore(recordId: id, operatorId: operatorId)
selectedListType = .queueing
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
}
/// /
func userMark(api: any ScenicQueueServing, scenicId: Int?, userId: String?, request: ScenicQueueUserMarkRequest) async throws {
try await api.scenicQueueUserMark(request)
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
}
///
func startRealtime(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
guard queueGatePassed, let scenicId, let scenicSpotId = selectedSpotId else {
stopRealtime()
return
}
guard realtimeScenicId != scenicId || realtimeSpotId != scenicSpotId else { return }
stopRealtime()
do {
let token = try await api.socketToken().socketToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { return }
realtimeScenicId = scenicId
realtimeSpotId = scenicSpotId
socketClient.connect(socketToken: token, scenicSpotId: scenicSpotId) { [weak self] message in
Task { @MainActor in
await self?.handleSocketMessage(message, api: api, scenicId: scenicId, userId: userId)
}
}
} catch {
errorMessage = error.localizedDescription
}
}
///
func stopRealtime() {
socketClient.disconnect()
realtimeScenicId = nil
realtimeSpotId = nil
pendingRemoteCalledRecordIds.removeAll()
handledRemoteCallEventIds.removeAll()
}
///
var shootingDurationSeconds: Int {
guard let scenicId = currentScenicId, let selectedSpotId,
let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId)
else {
return max(UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey), 0)
}
return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0)
}
/// SocketMessage
private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
guard message.isScenicQueueEvent,
let params = message.data?.params,
let changedSpotId = params.scenicSpotId,
let selectedSpotId,
changedSpotId == Int64(selectedSpotId)
else { return }
switch message.data?.action {
case ScenicQueueSocketMessage.queueUpdatedAction:
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
case ScenicQueueSocketMessage.ticketCalledAction:
await handleRemoteTicketCalled(params: params, api: api, scenicId: scenicId, userId: userId)
default:
return
}
}
/// RemoteTicketCalled
private func handleRemoteTicketCalled(params: ScenicQueueSocketParams, api: any ScenicQueueServing, scenicId: Int, userId: String?) async {
guard let recordId = params.recordId, recordId > 0 else { return }
if let operatorUid = params.operatorUid,
let myUid = Int64(userId ?? ""),
operatorUid == myUid {
return
}
let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !eventId.isEmpty, !handledRemoteCallEventIds.insert(eventId).inserted {
return
}
pendingRemoteCalledRecordIds.insert(recordId)
guard let selectedSpotId else { return }
_ = try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: selectedSpotId)
let tickets = (try? await loadQueueingTickets(api: api, scenicId: scenicId, scenicSpotId: selectedSpotId)) ?? []
if selectedListType == .queueing {
items = tickets
totalCount = max(totalCount, tickets.count)
hasMore = tickets.count < totalCount
}
consumePendingRemoteCalledTickets(from: tickets)
await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId)
}
/// QueueingTickets
private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] {
let home = try await api.scenicQueueHome(
scenicId: scenicId,
scenicSpotId: scenicSpotId,
type: QueueListType.queueing.rawValue,
page: 1,
pageSize: pageSize
)
return home.list?.list ?? []
}
/// consumeCalledTickets
private func consumePendingRemoteCalledTickets(from tickets: [QueueItem]) {
guard let ticket = tickets.first(where: {
pendingRemoteCalledRecordIds.contains($0.id) && ($0.isCalled == 1 || $0.status == 1 || $0.statusText.contains("已叫号"))
}) else {
return
}
pendingRemoteCalledRecordIds.remove(ticket.id)
remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode)
}
/// Page
private func loadPage(api: any ScenicQueueServing, scenicId: Int, userId: String?, page targetPage: Int) async throws {
guard let scenicSpotId = selectedSpotId else { return }
let home = try await api.scenicQueueHome(
scenicId: scenicId,
scenicSpotId: scenicSpotId,
type: selectedListType.rawValue,
page: targetPage,
pageSize: pageSize
)
let stats = (try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: scenicSpotId)) ?? ScenicQueueStatsData()
let block = home.list
let nextList = block?.list ?? []
if targetPage <= 1 {
items = nextList
} else {
items.append(contentsOf: nextList)
}
page = targetPage
totalCount = block?.total ?? items.count
hasMore = page * pageSize < totalCount
queueCount = stats.queueCount
avgWaitMin = stats.avgWaitMin
let syncText = stats.time.isEmpty ? (home.time ?? "") : stats.time
lastSyncTimeText = syncText.isEmpty ? "--" : syncText
refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId)
}
/// markCalled
private func markQueueCalled(id: Int64, statusText: String) {
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
items = items.map { item in
guard item.id == id else { return item }
return ScenicQueueTicket(
id: item.id,
queueCode: item.queueCode,
mobile: item.mobile,
status: max(item.status, 1),
statusText: trimmedStatus.nonEmptyOrDefault(item.statusText.contains("已叫号") ? item.statusText : "已叫号"),
waitMin: item.waitMin,
aheadCount: item.aheadCount,
isCalled: 1,
createdAt: item.createdAt,
calledAt: item.calledAt,
expiredAt: item.expiredAt,
finishedAt: item.finishedAt,
queueBanLabel: item.queueBanLabel,
identityTag: item.identityTag,
queueTime: item.queueTime,
queueCountToday: item.queueCountToday,
uid: item.uid,
markAsPhotog: item.markAsPhotog,
markAsFreelancePhotog: item.markAsFreelancePhotog,
isMissRequeue: item.isMissRequeue,
missRequeueText: item.missRequeueText
)
}
}
///
private func reset() {
stopRealtime()
loading = false
loadingMore = false
scenicSpots = []
selectedSpotId = nil
selectedSpotName = "--"
queueGatePassed = false
selectedListType = .queueing
currentScenicId = nil
clearQueueData()
}
///
private func clearQueueData() {
loadingMore = false
items = []
queueCount = 0
avgWaitMin = 0
totalCount = 0
hasMore = false
page = 1
lastSyncTimeText = "--"
}
/// ShootingCallConfig
private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) {
let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot()
showStartShootingButton = snapshot.showStartShootingButton
autoCallAheadCount = min(max(snapshot.autoCallAheadCount, 0), 5)
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
}
/// QueueSettingFromServerIfMatchesLocal
private func syncQueueSettingFromServerIfMatchesLocal(api: any ScenicQueueServing, userId: String?, scenicId: Int, spotId: Int) async {
guard let data = try? await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: spotId),
data.exists,
let setting = data.setting
else { return }
if let settingScenicId = setting.scenicId, settingScenicId != scenicId { return }
if let settingSpotId = setting.scenicSpotId, settingSpotId != spotId { return }
let local = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot()
let snapshot = ScenicQueueSettingsSnapshot(
shootMinute: min(max(setting.photoEstimateMin, 0), 30),
shootSecond: min(max(setting.photoEstimateSec, 0), 59),
firstAheadCount: min(max(setting.firstNoticeThresholdPos, 0), 50),
firstSms: setting.firstNoticeSmsEnabled == 1,
firstPhone: setting.firstNoticeCallEnabled == 1,
secondAheadCount: min(max(setting.secondNoticeThresholdPos, 0), 50),
secondSms: setting.secondNoticeSmsEnabled == 1,
secondPhone: setting.secondNoticeCallEnabled == 1,
broadcastIntervalSec: ScenicQueueSettingsViewModel.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec),
countdownThresholdSec: ScenicQueueSettingsViewModel.coercedCountdownThreshold(setting.countdownReadableThresholdSec),
queueDistanceMeter: min(max(setting.queueDistanceMeter, 0), 9_999_990),
queueTakeLimit: min(max(setting.queueTakeLimit, 0), 999_999),
missCallRequeueOffset: min(max(setting.missCallRequeueOffset, 1), 9_999),
showStartShootingButton: (setting.showStartShootButton ?? (local.showStartShootingButton ? 1 : 0)) == 1,
autoCallAheadCount: min(max(setting.autoCallNextCount ?? local.autoCallAheadCount, 0), 5),
quickCallButtonEnabled: local.quickCallButtonEnabled,
prepareCallButtonEnabled: local.prepareCallButtonEnabled,
businessOpen: setting.status == 1,
businessStartTime: setting.businessStartTime,
businessEndTime: setting.businessEndTime
)
ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: userId, scenicId: scenicId, spotId: spotId)
ScenicQueueSettingsStore.saveSelectedSpot(
id: spotId,
name: setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName),
userId: userId,
scenicId: scenicId
)
if !setting.remark.isEmpty, setting.remark != "A0001" {
ScenicQueueSettingsStore.saveCustomTtsText(setting.remark, userId: userId, scenicId: scenicId, spotId: spotId)
}
let voices = setting.voiceBroadcasts
.sorted { $0.sortOrder < $1.sortOrder }
.map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if !voices.isEmpty {
ScenicQueueSettingsStore.savePresetVoices(Array(voices.prefix(5)), userId: userId, scenicId: scenicId, spotId: spotId)
}
selectedSpotName = setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName)
}
}
@MainActor
/// ViewModel
final class ScenicQueueSettingsViewModel {
var onChange: (() -> Void)?
var loading = false { didSet { onChange?() } }
var message: String? { didSet { onChange?() } }
var scenicSpots: [ScenicSpotItem] = [] { didSet { onChange?() } }
var selectedSpotId: Int? { didSet { onChange?() } }
var photoEstimateMin = "0" { didSet { onChange?() } }
var photoEstimateSec = "30" { didSet { onChange?() } }
var firstAhead = "0" { didSet { onChange?() } }
var firstSms = false { didSet { onChange?() } }
var firstPhone = false { didSet { onChange?() } }
var secondAhead = "0" { didSet { onChange?() } }
var secondSms = false { didSet { onChange?() } }
var secondPhone = false { didSet { onChange?() } }
var broadcastIntervalSec = "50" { didSet { onChange?() } }
var countdownThresholdSec = "15" { didSet { onChange?() } }
var maxQueueRangeKm = "0.00" { didSet { onChange?() } }
var localQueueLimit = "0" { didSet { onChange?() } }
var localPassDelay = "1" { didSet { onChange?() } }
var showStartShootingButton = true { didSet { onChange?() } }
var autoCallAheadCount = "0" { didSet { onChange?() } }
var queueOpen = true { didSet { onChange?() } }
var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0) { didSet { onChange?() } }
var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0) { didSet { onChange?() } }
var customTtsText = "" { didSet { onChange?() } }
var presetVoices: [String] = [] { didSet { onChange?() } }
var quickCallButtonEnabled = false { didSet { onChange?() } }
var prepareCallButtonEnabled = false { didSet { onChange?() } }
var logs: [ScenicQueueSettingChangeLogItem] = [] { didSet { onChange?() } }
var qrcodeURL = "" { didSet { onChange?() } }
private let speaker = ScenicQueueSpeechService()
private var currentUserId: String?
private var currentScenicId: Int?
///
func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {
guard let scenicId else {
resetSettingsState()
message = "请先选择景区"
return
}
currentUserId = userId
currentScenicId = scenicId
scenicSpots = spots
message = nil
loading = true
defer { loading = false }
logs = []
let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId)
selectedSpotId = savedSpotId.flatMap { id in spots.first(where: { $0.id == id })?.id ?? id } ?? selectedSpotId ?? spots.first?.id
guard selectedSpotId != nil else { return }
await loadSelectedSpotSetting(api: api, scenicId: scenicId, userId: userId)
}
///
func loadSelectedSpotSetting(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async {
guard let scenicId, let selectedSpotId else { return }
currentUserId = userId
currentScenicId = scenicId
presetVoices = ScenicQueueSettingsStore.presetVoices(userId: userId, scenicId: scenicId, spotId: selectedSpotId)
let savedCustomText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: selectedSpotId)
if !savedCustomText.isEmpty, savedCustomText != "A0001" {
customTtsText = savedCustomText
}
if let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: selectedSpotId) {
apply(snapshot)
}
do {
let setting = try await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: selectedSpotId)
if setting.exists, let item = setting.setting {
apply(item)
ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot(), userId: userId, scenicId: scenicId, spotId: selectedSpotId)
}
} catch {
message = error.localizedDescription
}
}
///
func loadSettingChangeLogs(api: any ScenicQueueServing, scenicId: Int?) async {
guard let scenicId else {
message = "请先选择景区"
return
}
do {
let data = try await api.scenicQueueSettingChangeLog(scenicId: scenicId, scenicSpotId: selectedSpotId, page: 1, pageSize: 20)
logs = data.list
} catch {
message = error.localizedDescription
}
}
/// URL
func fetchQRCode(api: any ScenicQueueServing, scenicId: Int?) async throws -> String {
guard let scenicId else { throw APIError.networkFailed("请先选择景区") }
guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") }
let data = try await api.scenicQueueShootQueueQRCode(scenicId: scenicId, scenicSpotId: selectedSpotId)
let url = data.qrcodeUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !url.isEmpty else { throw APIError.networkFailed("服务端未返回二维码图片地址") }
qrcodeURL = url
return url
}
///
func saveQRCodeToPhotoLibrary(qrcodeURL: String) async throws {
let trimmed = qrcodeURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw APIError.networkFailed("服务端未返回二维码图片地址")
}
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
guard status == .authorized || status == .limited else {
throw APIError.networkFailed("请在系统设置中允许保存图片到相册")
}
let image = try await loadQRCodeImage(from: trimmed)
try await PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: image)
}
}
///
func save(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async throws {
guard let scenicId else { throw APIError.networkFailed("请先选择景区") }
guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") }
currentUserId = userId
currentScenicId = scenicId
let min = try AppFormValidator.rangedInteger(photoEstimateMin, name: "拍摄时长分钟", range: 0...30)
let sec = try AppFormValidator.rangedInteger(photoEstimateSec, name: "拍摄时长秒", range: 0...59)
guard min > 0 || sec > 0 else { throw APIError.networkFailed("拍摄时长须大于 0") }
let first = try AppFormValidator.rangedInteger(firstAhead, name: "第一次通知阈值", range: 0...50)
let second = try AppFormValidator.rangedInteger(secondAhead, name: "第二次通知阈值", range: 0...50)
try AppFormValidator.validateQueueNoticeThresholds(first: first, second: second)
let interval = try AppFormValidator.rangedInteger(broadcastIntervalSec, name: "播报间隔时间", range: 40...60)
let countdown = try AppFormValidator.rangedInteger(countdownThresholdSec, name: "进入读秒倒计时阈值", range: 10...30)
let autoCallAhead = try AppFormValidator.rangedInteger(autoCallAheadCount, name: "自动叫号后续几位", range: 0...5)
maxQueueRangeKm = try normalizedMaxQueueRangeForDisplay()
let queueDistanceMeter = try queueDistanceMeterForSubmit()
let queueTakeLimit = try AppFormValidator.rangedInteger(localQueueLimit, name: "排队次数限制", range: 0...999_999)
let missCallRequeueOffset = try AppFormValidator.rangedInteger(localPassDelay, name: "过号顺延", range: 1...9_999)
guard customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).count <= 255 else {
throw APIError.networkFailed("自定义播报文本最多 255 个字")
}
loading = true
defer { loading = false }
try await api.scenicQueueSaveSetting(ScenicQueueSaveSettingRequest(
scenicId: scenicId,
scenicSpotId: selectedSpotId,
photoEstimateMin: min,
photoEstimateSec: sec,
firstNoticeThresholdPos: first,
firstNoticeSmsEnabled: firstSms ? 1 : 0,
firstNoticeCallEnabled: firstPhone ? 1 : 0,
secondNoticeThresholdPos: second,
secondNoticeSmsEnabled: secondSms ? 1 : 0,
secondNoticeCallEnabled: secondPhone ? 1 : 0,
countdownBroadcastIntervalSec: interval,
countdownReadableThresholdSec: countdown,
queueDistanceMeter: queueDistanceMeter,
queueTakeLimit: queueTakeLimit,
missCallRequeueOffset: missCallRequeueOffset,
showStartShootButton: showStartShootingButton ? 1 : 0,
autoCallNextCount: autoCallAhead,
businessStartTime: Self.businessTimePayload(businessStartTime),
businessEndTime: Self.businessTimePayload(businessEndTime),
voiceBroadcasts: voiceBroadcastsForSavePayload(),
status: queueOpen ? 1 : 0,
remark: customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
))
let spotName = scenicSpots.first(where: { $0.id == selectedSpotId })?.name ?? ""
ScenicQueueSettingsStore.saveSelectedSpot(id: selectedSpotId, name: spotName, userId: userId, scenicId: scenicId)
saveTimingSnapshot(min: min, sec: sec, interval: interval, countdown: countdown)
saveShootingCallSettings(autoCallAhead: autoCallAhead)
saveCustomTextLocally()
ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: userId, scenicId: scenicId, spotId: selectedSpotId)
ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot(
min: min,
sec: sec,
first: first,
second: second,
interval: interval,
countdown: countdown,
queueDistanceMeter: queueDistanceMeter,
queueTakeLimit: queueTakeLimit,
missCallRequeueOffset: missCallRequeueOffset,
autoCallAhead: autoCallAhead
), userId: userId, scenicId: scenicId, spotId: selectedSpotId)
}
///
func saveCustomTextAsPreset() {
let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.count <= 255 else {
message = "自定义播报文本最多 255 个字"
return
}
saveCustomTextLocally()
if !text.isEmpty, !presetVoices.contains(text), presetVoices.count < 5 {
presetVoices.append(text)
}
if let scenicId = currentScenicId, let selectedSpotId {
ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId)
}
}
///
func updateMaxQueueRange(_ value: String) {
maxQueueRangeKm = AppFormValidator.sanitizedMoneyInput(value, maxDecimalPlaces: 2)
}
///
func playTestSound() {
speaker.speak("蓝牙音响测试音,请确认外放音量是否合适。")
}
///
func playCustomText() {
let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
message = "请输入要播报的文字"
return
}
speaker.speak(text)
}
///
func deletePresetVoice(at index: Int) {
guard presetVoices.indices.contains(index) else { return }
presetVoices.remove(at: index)
}
/// SettingsState
private func resetSettingsState() {
loading = false
scenicSpots = []
selectedSpotId = nil
photoEstimateMin = "0"
photoEstimateSec = "30"
firstAhead = "0"
firstSms = false
firstPhone = false
secondAhead = "0"
secondSms = false
secondPhone = false
broadcastIntervalSec = "50"
countdownThresholdSec = "15"
maxQueueRangeKm = "0.00"
localQueueLimit = "0"
localPassDelay = "1"
showStartShootingButton = true
autoCallAheadCount = "0"
queueOpen = true
businessStartTime = Self.businessTime(hour: 10, minute: 0)
businessEndTime = Self.businessTime(hour: 20, minute: 0)
customTtsText = ""
logs = []
presetVoices = []
quickCallButtonEnabled = false
prepareCallButtonEnabled = false
qrcodeURL = ""
}
/// apply
private func apply(_ setting: ScenicQueueSettingItem?) {
guard let setting else { return }
if let spotId = setting.scenicSpotId { selectedSpotId = spotId }
photoEstimateMin = "\(setting.photoEstimateMin)"
photoEstimateSec = "\(setting.photoEstimateSec)"
firstAhead = "\(setting.firstNoticeThresholdPos)"
firstSms = setting.firstNoticeSmsEnabled == 1
firstPhone = setting.firstNoticeCallEnabled == 1
secondAhead = "\(setting.secondNoticeThresholdPos)"
secondSms = setting.secondNoticeSmsEnabled == 1
secondPhone = setting.secondNoticeCallEnabled == 1
broadcastIntervalSec = "\(Self.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec))"
countdownThresholdSec = "\(Self.coercedCountdownThreshold(setting.countdownReadableThresholdSec))"
maxQueueRangeKm = Self.queueDistanceDisplay(meters: setting.queueDistanceMeter)
localQueueLimit = "\(min(max(setting.queueTakeLimit, 0), 999_999))"
localPassDelay = "\(min(max(setting.missCallRequeueOffset, 1), 9_999))"
if let showStartShootButton = setting.showStartShootButton { showStartShootingButton = showStartShootButton == 1 }
if let autoCallNextCount = setting.autoCallNextCount { autoCallAheadCount = "\(min(max(autoCallNextCount, 0), 5))" }
queueOpen = setting.status == 1
businessStartTime = Self.parseBusinessTime(setting.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0))
businessEndTime = Self.parseBusinessTime(setting.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0))
if !setting.remark.isEmpty, setting.remark != "A0001" { customTtsText = setting.remark }
let voices = setting.voiceBroadcasts
.sorted { $0.sortOrder < $1.sortOrder }
.map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) }
}
/// apply
private func apply(_ snapshot: ScenicQueueSettingsSnapshot) {
photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))"
photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))"
firstAhead = "\(min(max(snapshot.firstAheadCount, 0), 50))"
firstSms = snapshot.firstSms
firstPhone = snapshot.firstPhone
secondAhead = "\(min(max(snapshot.secondAheadCount, 0), 50))"
secondSms = snapshot.secondSms
secondPhone = snapshot.secondPhone
broadcastIntervalSec = "\(Self.coercedBroadcastInterval(snapshot.broadcastIntervalSec))"
countdownThresholdSec = "\(Self.coercedCountdownThreshold(snapshot.countdownThresholdSec))"
maxQueueRangeKm = Self.queueDistanceDisplay(meters: snapshot.queueDistanceMeter)
localQueueLimit = "\(min(max(snapshot.queueTakeLimit, 0), 999_999))"
localPassDelay = "\(min(max(snapshot.missCallRequeueOffset, 1), 9_999))"
showStartShootingButton = snapshot.showStartShootingButton
autoCallAheadCount = "\(min(max(snapshot.autoCallAheadCount, 0), 5))"
quickCallButtonEnabled = snapshot.quickCallButtonEnabled
prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled
queueOpen = snapshot.businessOpen
businessStartTime = Self.parseBusinessTime(snapshot.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0))
businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0))
}
/// dMaxQueueRangeForDisplay
private func normalizedMaxQueueRangeForDisplay() throws -> String {
let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm)
guard !normalized.isEmpty,
normalized.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil,
let value = Decimal(string: normalized, locale: Locale(identifier: "en_US_POSIX"))
else {
throw APIError.networkFailed("允许排队的最大范围格式不正确")
}
let max = Decimal(999_999) / Decimal(100)
guard value >= 0, value <= max else {
throw APIError.networkFailed("允许排队的最大范围须在 09999.99")
}
return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue)
}
/// queueDistanceMeter
private func queueDistanceMeterForSubmit() throws -> Int {
_ = try normalizedMaxQueueRangeForDisplay()
let value = Decimal(string: maxQueueRangeKm, locale: Locale(identifier: "en_US_POSIX")) ?? 0
return NSDecimalNumber(decimal: value * Decimal(1000)).intValue
}
/// QRCodeImage
private func loadQRCodeImage(from text: String) async throws -> UIImage {
if let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased()) {
let (data, _) = try await URLSession.shared.data(from: url)
if let image = UIImage(data: data) { return image }
}
if let generated = Self.generateQRCode(from: text) { return generated }
throw APIError.networkFailed("二维码图片生成失败")
}
/// generateQRCode
private static func generateQRCode(from string: String) -> UIImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
guard let outputImage = filter.outputImage else { return nil }
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: 8, y: 8))
let context = CIContext()
guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
/// saveTiming
private func saveTimingSnapshot(min: Int, sec: Int, interval: Int, countdown: Int) {
UserDefaults.standard.set(max(min * 60 + sec, 0), forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey)
UserDefaults.standard.set(interval, forKey: ScenicQueueLocalSettings.broadcastIntervalSecondsKey)
UserDefaults.standard.set(countdown, forKey: ScenicQueueLocalSettings.countdownThresholdSecondsKey)
}
/// saveShootingCall
private func saveShootingCallSettings(autoCallAhead: Int) {
UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey)
UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey)
UserDefaults.standard.set(quickCallButtonEnabled, forKey: ScenicQueueLocalSettings.quickCallButtonEnabledKey)
UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey)
}
/// saveLocally
private func saveCustomTextLocally() {
ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId)
}
/// voiceBroadcasts
private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] {
Array(presetVoices
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.prefix(5)
.enumerated()
.map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) })
}
/// Snapshot
private func makeSnapshot(
min: Int? = nil,
sec: Int? = nil,
first: Int? = nil,
second: Int? = nil,
interval: Int? = nil,
countdown: Int? = nil,
queueDistanceMeter: Int? = nil,
queueTakeLimit: Int? = nil,
missCallRequeueOffset: Int? = nil,
autoCallAhead: Int? = nil
) -> ScenicQueueSettingsSnapshot {
ScenicQueueSettingsSnapshot(
shootMinute: min ?? (Int(photoEstimateMin) ?? 0),
shootSecond: sec ?? (Int(photoEstimateSec) ?? 30),
firstAheadCount: first ?? (Int(firstAhead) ?? 0),
firstSms: firstSms,
firstPhone: firstPhone,
secondAheadCount: second ?? (Int(secondAhead) ?? 0),
secondSms: secondSms,
secondPhone: secondPhone,
broadcastIntervalSec: interval ?? (Int(broadcastIntervalSec) ?? 50),
countdownThresholdSec: countdown ?? (Int(countdownThresholdSec) ?? 15),
queueDistanceMeter: queueDistanceMeter ?? ((Decimal(string: maxQueueRangeKm) ?? 0) as NSDecimalNumber).intValue * 1000,
queueTakeLimit: queueTakeLimit ?? (Int(localQueueLimit) ?? 0),
missCallRequeueOffset: missCallRequeueOffset ?? (Int(localPassDelay) ?? 1),
showStartShootingButton: showStartShootingButton,
autoCallAheadCount: autoCallAhead ?? (Int(autoCallAheadCount) ?? 0),
quickCallButtonEnabled: quickCallButtonEnabled,
prepareCallButtonEnabled: prepareCallButtonEnabled,
businessOpen: queueOpen,
businessStartTime: Self.businessTimePayload(businessStartTime),
businessEndTime: Self.businessTimePayload(businessEndTime)
)
}
/// coerced
static func coercedBroadcastInterval(_ raw: Int) -> Int {
(40...60).contains(raw) ? raw : 50
}
/// coerced
static func coercedCountdownThreshold(_ raw: Int) -> Int {
(10...30).contains(raw) ? raw : 15
}
/// business
static func businessTime(hour: Int, minute: Int) -> Date {
Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date()
}
/// BusinessTime
static func parseBusinessTime(_ raw: String, fallback: Date) -> Date {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return fallback }
for format in ["HH:mm", "HH:mm:ss"] {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = format
if let date = formatter.date(from: text) { return date }
}
return fallback
}
/// business
static func businessTimePayload(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
/// queueDistanceDisplay
private static func queueDistanceDisplay(meters: Int) -> String {
let clamped = min(max(meters, 0), 9_999_990)
return String(format: "%.2f", Double(clamped) / 1000)
}
}
private extension String {
var nilIfEmpty: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
/// non
func nonEmptyOrDefault(_ fallback: String) -> String {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? fallback : text
}
}