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>
912 lines
42 KiB
Swift
912 lines
42 KiB
Swift
//
|
||
// 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 ?? []
|
||
}
|
||
|
||
/// consume待处理远程CalledTickets相关逻辑。
|
||
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)
|
||
}
|
||
|
||
/// mark排队Called相关逻辑。
|
||
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("允许排队的最大范围须在 0~9999.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)
|
||
}
|
||
|
||
/// save自定义文本Locally相关逻辑。
|
||
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
|
||
}
|
||
}
|