// // ScenicQueueSettingsViewModel.swift // suixinkan // import Foundation /// 排队设置页 ViewModel,对齐 Android `ScenicQueueSettingsViewModel`。 final class ScenicQueueSettingsViewModel { private enum Limits { static let remarkMaxLen = 255 static let shootMinute = 0...30 static let shootSecond = 0...59 static let ahead = 0...50 static let broadcastInterval = 40...60 static let readableThreshold = 10...30 static let queueDistance = 0...9_999_990 static let queueTakeLimit = 0...999_999 static let missCallRequeueOffset = 1...9_999 static let autoCallAheadCount = 0...5 } private(set) var punchSpotOptions: [ScenicQueuePunchSpotOption] = [] private(set) var selectedPunchSpotId: String? private(set) var selectedPunchSpotLabel: String private(set) var shootMinute: Int private(set) var shootSecond: Int private(set) var firstAheadCount: Int private(set) var firstSms: Bool private(set) var firstPhone: Bool private(set) var secondAheadCount: Int private(set) var secondSms: Bool private(set) var secondPhone: Bool private(set) var broadcastIntervalSec: Int private(set) var countdownThresholdSec: Int private(set) var queueDistanceMeter: Int private(set) var queueTakeLimit: Int private(set) var missCallRequeueOffset: Int private(set) var showStartShootingButton: Bool private(set) var autoCallAheadCount: Int private(set) var quickCallButtonEnabled: Bool private(set) var prepareCallButtonEnabled: Bool private(set) var businessOpen: Bool private(set) var businessStartTime: String private(set) var businessEndTime: String private(set) var customTTSText: String private(set) var presetVoices: [String] private(set) var settingsTab: ScenicQueueSettingsTab = .basic private(set) var highlightedInvalidFieldKeys: Set = [] var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onNavigateBack: (() -> Void)? var onNavigateLog: (() -> Void)? private let appStore: AppStore private let ttsManager: ScenicQueueTTSManaging private var punchSpotSettingRequestGeneration = 0 private var ttsLoopAnchorText: String? init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) { self.appStore = appStore self.ttsManager = ttsManager let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() selectedPunchSpotId = appStore.scenicQueuePunchSpotId.nonEmptyTrimmed selectedPunchSpotLabel = appStore.scenicQueuePunchSpotName shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute) shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond) firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead) firstSms = snapshot.firstSms firstPhone = snapshot.firstPhone secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead) secondSms = snapshot.secondSms secondPhone = snapshot.secondPhone broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval) countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold) queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance) queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit) missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset) showStartShootingButton = snapshot.showStartShootingButton autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount) quickCallButtonEnabled = snapshot.quickCallButtonEnabled prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled businessOpen = snapshot.businessOpen businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00") businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00") customTTSText = appStore.scenicQueueRemark presetVoices = appStore.scenicQueuePresetVoices() } var selectedPunchSpotDisplayText: String { if let selectedPunchSpotId, let option = punchSpotOptions.first(where: { $0.id == selectedPunchSpotId }), !option.label.isEmpty { return option.label } if !selectedPunchSpotLabel.isEmpty { return selectedPunchSpotLabel } if let selectedPunchSpotId, !selectedPunchSpotId.isEmpty { return "打卡点(ID \(selectedPunchSpotId))" } return "请选择打卡点" } /// 是否优先使用离线语音合成。 var useOfflineTTS: Bool { appStore.scenicQueueUseOfflineTTS } func openSettingChangeLog() { onNavigateLog?() } /// 打开打卡点选择器前加载全部打卡点。 func loadPunchSpotOptions(api: ScenicQueueAPIProtocol) async { guard appStore.currentScenicId > 0 else { onShowMessage?("请先选择景区") return } do { let response = try await api.scenicSpotListAll(scenicId: String(appStore.currentScenicId)) punchSpotOptions = response.list.map { ScenicQueuePunchSpotOption(id: String($0.id), label: $0.name) } if punchSpotOptions.isEmpty { onShowMessage?("暂无可选打卡点") } notifyStateChange() } catch { onShowMessage?(error.localizedDescription) } } /// 选择打卡点并尝试拉取服务端设置。 func setPunchSpot(_ option: ScenicQueuePunchSpotOption, api: ScenicQueueAPIProtocol) async { selectedPunchSpotId = option.id selectedPunchSpotLabel = option.label.trimmingCharacters(in: .whitespacesAndNewlines) highlightedInvalidFieldKeys.remove("punchSpot") notifyStateChange() await fetchQueueSettingAfterPunchSpotSelected(option.id, api: api) } func setSettingsTab(_ tab: ScenicQueueSettingsTab) { settingsTab = tab notifyStateChange() } func setShootMinute(_ value: Int) { shootMinute = value.clamped(to: Limits.shootMinute); notifyStateChange() } func setShootSecond(_ value: Int) { shootSecond = value.clamped(to: Limits.shootSecond); notifyStateChange() } func setFirstAheadCount(_ value: Int) { firstAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() } func setSecondAheadCount(_ value: Int) { secondAheadCount = value.clamped(to: Limits.ahead); notifyStateChange() } func setBroadcastIntervalSec(_ value: Int) { broadcastIntervalSec = value.clamped(to: Limits.broadcastInterval); notifyStateChange() } func setCountdownThresholdSec(_ value: Int) { countdownThresholdSec = value.clamped(to: Limits.readableThreshold); notifyStateChange() } func setQueueDistanceMeter(_ value: Int) { queueDistanceMeter = value.clamped(to: Limits.queueDistance); notifyStateChange() } func setQueueTakeLimit(_ value: Int) { queueTakeLimit = value.clamped(to: Limits.queueTakeLimit); notifyStateChange() } func setMissCallRequeueOffset(_ value: Int) { missCallRequeueOffset = value.clamped(to: Limits.missCallRequeueOffset); notifyStateChange() } func setAutoCallAheadCount(_ value: Int) { autoCallAheadCount = value.clamped(to: Limits.autoCallAheadCount); notifyStateChange() } func toggleShowStartShootingButton() { showStartShootingButton.toggle(); notifyStateChange() } func toggleQuickCallButtonEnabled() { quickCallButtonEnabled.toggle(); notifyStateChange() } func togglePrepareCallButtonEnabled() { prepareCallButtonEnabled.toggle(); notifyStateChange() } func toggleBusinessOpen() { businessOpen.toggle(); notifyStateChange() } func toggleFirstSms() { firstSms.toggle(); notifyStateChange() } func toggleFirstPhone() { firstPhone.toggle(); notifyStateChange() } func toggleSecondSms() { secondSms.toggle(); notifyStateChange() } func toggleSecondPhone() { secondPhone.toggle(); notifyStateChange() } func setBusinessStartTime(_ value: String) { businessStartTime = Self.normalizedTime(value, fallback: businessStartTime) notifyStateChange() } func setBusinessEndTime(_ value: String) { businessEndTime = Self.normalizedTime(value, fallback: businessEndTime) notifyStateChange() } /// 设置离线语音合成偏好。 func setUseOfflineTTS(_ enabled: Bool) { guard enabled else { appStore.scenicQueueUseOfflineTTS = false notifyStateChange() return } guard ttsManager.supportsOfflineTTS else { appStore.scenicQueueUseOfflineTTS = false onShowMessage?("离线语音资源未安装") notifyStateChange() return } appStore.scenicQueueUseOfflineTTS = true notifyStateChange() } func setCustomTTSText(_ value: String) { customTTSText = String(value.prefix(Limits.remarkMaxLen)) notifyStateChange() } /// 播放或停止自定义 TTS 文案。 func toggleCustomTTSPlayback() { if ttsManager.customTextLooping { ttsManager.stopCustomTextLoop() ttsLoopAnchorText = nil notifyStateChange() return } let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { onShowMessage?("请输入要播报的文字") return } Task { let ok = await ttsManager.startCustomTextLoop(text) if ok { ttsLoopAnchorText = text } else { ttsLoopAnchorText = nil onShowMessage?("播报失败,请检查网络与语音服务") } notifyStateChange() } } /// 播放蓝牙测试音。 func playBluetoothTestSound() { Task { let ok = await ttsManager.speakPlainTextOnce("蓝牙音响测试音,请确认外放音量是否合适。") if !ok { onShowMessage?("测试音播放失败,请检查网络与语音服务") } } } /// 保存当前文案到本地预设。 func saveCurrentTextAsPresetVoiceLocal() { guard appStore.currentScenicId > 0, selectedPunchSpotId?.nonEmptyTrimmed != nil else { onShowMessage?("请先选择打卡点") return } let text = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { onShowMessage?("请输入要播报的文字") return } if presetVoices.count >= 5 { onShowMessage?("最多保存5个语音") return } if presetVoices.contains(text) { onShowMessage?("该文案已在列表中") return } presetVoices.append(text) appStore.saveScenicQueuePresetVoices(presetVoices) onShowMessage?("已保存到本地") notifyStateChange() } func deletePresetVoice(at index: Int) { guard presetVoices.indices.contains(index) else { return } let removed = presetVoices.remove(at: index) appStore.saveScenicQueuePresetVoices(presetVoices) if ttsLoopAnchorText == removed { ttsManager.stopCustomTextLoop() ttsLoopAnchorText = nil } notifyStateChange() } /// 保存排队打卡点二维码到相册。 func saveShootQueueQRCode(api: ScenicQueueAPIProtocol) async { guard let ids = currentScenicAndSpotIdsForSelected() else { onShowMessage?("请先选择打卡点") return } do { let data = try await api.shootQueueQrcode(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId) guard let url = data.qrcodeUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty else { onShowMessage?("服务端未返回二维码图片地址") return } try await ScenicQueueQRCodeSaver.saveImage(from: url) onShowMessage?("保存成功") } catch { onShowMessage?((error as? LocalizedError)?.errorDescription ?? error.localizedDescription) } } /// 校验并保存设置。 func save(api: ScenicQueueAPIProtocol) async { highlightedInvalidFieldKeys = [] guard let ids = currentScenicAndSpotIdsForSelected() else { highlightedInvalidFieldKeys = ["punchSpot"] settingsTab = .basic onShowMessage?("请选择打卡点") notifyStateChange() return } let validation = buildSaveValidation() guard validation.messages.isEmpty else { highlightedInvalidFieldKeys = validation.keys settingsTab = tabForValidationKeys(validation.keys) onShowMessage?(validation.messages.joined(separator: "\n")) notifyStateChange() return } let remark = customTTSText.trimmingCharacters(in: .whitespacesAndNewlines) let request = ScenicQueueSaveSettingRequest( scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId, photoEstimateMin: shootMinute, photoEstimateSec: shootSecond, firstNoticeThresholdPos: firstAheadCount, firstNoticeSmsEnabled: firstSms ? 1 : 0, firstNoticeCallEnabled: firstPhone ? 1 : 0, secondNoticeThresholdPos: secondAheadCount, secondNoticeSmsEnabled: secondSms ? 1 : 0, secondNoticeCallEnabled: secondPhone ? 1 : 0, countdownBroadcastIntervalSec: broadcastIntervalSec, countdownReadableThresholdSec: countdownThresholdSec, queueDistanceMeter: queueDistanceMeter, queueTakeLimit: queueTakeLimit, missCallRequeueOffset: missCallRequeueOffset, showStartShootButton: showStartShootingButton ? 1 : 0, autoCallNextCount: autoCallAheadCount, businessStartTime: businessStartTime, businessEndTime: businessEndTime, voiceBroadcasts: presetVoices.enumerated().map { ScenicQueueVoiceBroadcastItem(content: $0.element, sortOrder: $0.offset + 1) }, status: businessOpen ? 1 : 0, remark: remark.isEmpty ? nil : remark ) do { try await api.saveSetting(request) appStore.scenicQueuePunchSpotId = String(ids.scenicSpotId) appStore.scenicQueuePunchSpotName = selectedPunchSpotLabel appStore.scenicQueueRemark = remark appStore.saveScenicQueuePresetVoices(presetVoices) appStore.saveScenicQueueSettingsSnapshot(currentSnapshot()) _ = try? await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId) onShowMessage?("保存成功") onNavigateBack?() } catch { onShowMessage?(error.localizedDescription) } } private func fetchQueueSettingAfterPunchSpotSelected(_ spotId: String, api: ScenicQueueAPIProtocol) async { guard let scenicId = Int64("\(appStore.currentScenicId)"), let scenicSpotId = Int64(spotId), scenicId > 0, scenicSpotId > 0 else { return } punchSpotSettingRequestGeneration += 1 let generation = punchSpotSettingRequestGeneration do { let data = try await api.setting(scenicId: scenicId, scenicSpotId: scenicSpotId) guard generation == punchSpotSettingRequestGeneration, selectedPunchSpotId == spotId, data.exists, let setting = data.setting else { return } applyServerSettingToForm(setting) } catch { // Keep local form values if remote setting cannot be loaded. } } private func applyServerSettingToForm(_ setting: ScenicQueueSettingItem) { let fallback = ScenicQueueSettingsSnapshot( showStartShootingButton: showStartShootingButton, autoCallAheadCount: autoCallAheadCount, quickCallButtonEnabled: quickCallButtonEnabled, prepareCallButtonEnabled: prepareCallButtonEnabled ) let snapshot = setting.toSnapshot(fallback: fallback) apply(snapshot) customTTSText = String((setting.remark ?? "").prefix(Limits.remarkMaxLen)) if let voices = setting.voiceBroadcasts { presetVoices = voices .sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) } .compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed } .prefix(5) .map { $0 } } if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmptyTrimmed { selectedPunchSpotLabel = name } highlightedInvalidFieldKeys = [] notifyStateChange() } private func apply(_ snapshot: ScenicQueueSettingsSnapshot) { shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute) shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond) firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead) firstSms = snapshot.firstSms firstPhone = snapshot.firstPhone secondAheadCount = snapshot.secondAheadCount.clamped(to: Limits.ahead) secondSms = snapshot.secondSms secondPhone = snapshot.secondPhone broadcastIntervalSec = snapshot.broadcastIntervalSec.clamped(to: Limits.broadcastInterval) countdownThresholdSec = snapshot.countdownThresholdSec.clamped(to: Limits.readableThreshold) queueDistanceMeter = snapshot.queueDistanceMeter.clamped(to: Limits.queueDistance) queueTakeLimit = snapshot.queueTakeLimit.clamped(to: Limits.queueTakeLimit) missCallRequeueOffset = snapshot.missCallRequeueOffset.clamped(to: Limits.missCallRequeueOffset) showStartShootingButton = snapshot.showStartShootingButton autoCallAheadCount = snapshot.autoCallAheadCount.clamped(to: Limits.autoCallAheadCount) quickCallButtonEnabled = snapshot.quickCallButtonEnabled prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled businessOpen = snapshot.businessOpen businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00") businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00") } private func currentSnapshot() -> ScenicQueueSettingsSnapshot { ScenicQueueSettingsSnapshot( shootMinute: shootMinute, shootSecond: shootSecond, firstAheadCount: firstAheadCount, firstSms: firstSms, firstPhone: firstPhone, secondAheadCount: secondAheadCount, secondSms: secondSms, secondPhone: secondPhone, broadcastIntervalSec: broadcastIntervalSec, countdownThresholdSec: countdownThresholdSec, queueDistanceMeter: queueDistanceMeter, queueTakeLimit: queueTakeLimit, missCallRequeueOffset: missCallRequeueOffset, showStartShootingButton: showStartShootingButton, autoCallAheadCount: autoCallAheadCount, quickCallButtonEnabled: quickCallButtonEnabled, prepareCallButtonEnabled: prepareCallButtonEnabled, businessOpen: businessOpen, businessStartTime: businessStartTime, businessEndTime: businessEndTime ) } private func buildSaveValidation() -> (messages: [String], keys: Set) { var messages: [String] = [] var keys: Set = [] if !Limits.shootMinute.contains(shootMinute) { messages.append("拍摄时长:分钟须在 0~30,且按 1 分钟递增") keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue) } if !Limits.shootSecond.contains(shootSecond) { messages.append("拍摄时长:秒须在 0~59") keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue) } if shootMinute == 0 && shootSecond == 0 { messages.append("拍摄时长须大于 0") keys.insert(ScenicQueueNumericFieldKey.shootMinute.rawValue) keys.insert(ScenicQueueNumericFieldKey.shootSecond.rawValue) } if !Limits.broadcastInterval.contains(broadcastIntervalSec) { messages.append("播报间隔时间须在 40~60 秒") keys.insert(ScenicQueueNumericFieldKey.broadcastIntervalSec.rawValue) } if !Limits.readableThreshold.contains(countdownThresholdSec) { messages.append("进入读秒倒计时阈值须在 10~30 秒") keys.insert(ScenicQueueNumericFieldKey.countdownReadableThresholdSec.rawValue) } return (messages, keys) } private func tabForValidationKeys(_ keys: Set) -> ScenicQueueSettingsTab { if keys.contains("punchSpot") || keys.contains(ScenicQueueNumericFieldKey.queueDistanceMeter.rawValue) || keys.contains(ScenicQueueNumericFieldKey.shootMinute.rawValue) || keys.contains(ScenicQueueNumericFieldKey.shootSecond.rawValue) || keys.contains(ScenicQueueNumericFieldKey.queueTakeLimit.rawValue) || keys.contains(ScenicQueueNumericFieldKey.missCallRequeueOffset.rawValue) { return .basic } if keys.contains(ScenicQueueNumericFieldKey.firstNoticeAhead.rawValue) || keys.contains(ScenicQueueNumericFieldKey.secondNoticeAhead.rawValue) { return .notify } return .voice } private func currentScenicAndSpotIdsForSelected() -> (scenicId: Int64, scenicSpotId: Int64)? { guard let scenicId = Int64("\(appStore.currentScenicId)"), scenicId > 0, let spotIdText = selectedPunchSpotId?.trimmingCharacters(in: .whitespacesAndNewlines), let scenicSpotId = Int64(spotIdText), scenicSpotId > 0 else { return nil } return (scenicId, scenicSpotId) } private static func normalizedTime(_ raw: String, fallback: String) -> String { let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_CN") for format in ["HH:mm", "HH:mm:ss"] { formatter.dateFormat = format if let date = formatter.date(from: text) { formatter.dateFormat = "HH:mm" return formatter.string(from: date) } } return fallback } private func notifyStateChange() { onStateChange?() } } private extension Int { func clamped(to range: ClosedRange) -> Int { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } private extension String { var nonEmptyTrimmed: String? { isEmpty ? nil : self } }