Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,882 @@
//
// 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)
}
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 {
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)
}
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("允许排队的最大范围须在 09999.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
}
}