// // QueueManagementViewModels.swift // suixinkan // // Created by Codex on 2026/6/25. // import Foundation import Combine import Photos import SwiftUI import UIKit @MainActor /// 排队管理列表 ViewModel,负责打卡点门禁、列表分页、动作和实时事件。 final class QueueManagementViewModel: ObservableObject { @Published var loading = false @Published var loadingMore = false @Published var items: [QueueItem] = [] @Published var scenicSpots: [ScenicSpotItem] = [] @Published var selectedSpotId: Int? @Published var selectedListType: QueueListType = .queueing @Published var queueCount = 0 @Published var avgWaitMin = 0.0 @Published var totalCount = 0 @Published var hasMore = false @Published var page = 1 @Published var lastSyncTimeText = "--" @Published var queueGatePassed = false @Published var selectedSpotName = "--" @Published var showStartShootingButton = true @Published var autoCallAheadCount = 0 @Published var quickCallButtonEnabled = false @Published var prepareCallButtonEnabled = false @Published var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement? @Published var errorMessage: String? private let pageSize = 10 private let socketClient = ScenicQueueSocketClient() @Published private var realtimeSpotId: Int? @Published private var realtimeScenicId: Int? @Published private var currentUserId: String? @Published private var currentScenicId: Int? @Published private var pendingRemoteCalledRecordIds = Set() @Published private var handledRemoteCallEventIds = Set() /// 待处理排队数量。 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) } 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 } } 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) } 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 ?? [] } 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) } 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) } 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 = "--" } 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 } 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: ObservableObject { @Published var loading = false @Published var message: String? @Published var scenicSpots: [ScenicSpotItem] = [] @Published var selectedSpotId: Int? @Published var photoEstimateMin = "0" @Published var photoEstimateSec = "30" @Published var firstAhead = "0" @Published var firstSms = false @Published var firstPhone = false @Published var secondAhead = "0" @Published var secondSms = false @Published var secondPhone = false @Published var broadcastIntervalSec = "50" @Published var countdownThresholdSec = "15" @Published var maxQueueRangeKm = "0.00" @Published var localQueueLimit = "0" @Published var localPassDelay = "1" @Published var showStartShootingButton = true @Published var autoCallAheadCount = "0" @Published var queueOpen = true @Published var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0) @Published var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0) @Published var customTtsText = "" @Published var presetVoices: [String] = [] @Published var quickCallButtonEnabled = false @Published var prepareCallButtonEnabled = false @Published var logs: [ScenicQueueSettingChangeLogItem] = [] @Published var qrcodeURL = "" private let speaker = ScenicQueueSpeechService() @Published private var currentUserId: String? @Published 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) } 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 = "" } 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)) } } 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)) } 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("允许排队的最大范围须在 0~9999.99") } return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue) } 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 } 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("二维码图片生成失败") } 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) } 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) } 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) } private func saveCustomTextLocally() { ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId) } 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) }) } 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) ) } static func coercedBroadcastInterval(_ raw: Int) -> Int { (40...60).contains(raw) ? raw : 50 } static func coercedCountdownThreshold(_ raw: Int) -> Int { (10...30).contains(raw) ? raw : 15 } static func businessTime(hour: Int, minute: Int) -> Date { Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date() } 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 } 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) } 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 } func nonEmptyOrDefault(_ fallback: String) -> String { let text = trimmingCharacters(in: .whitespacesAndNewlines) return text.isEmpty ? fallback : text } }