完善景区排队设置页与全局按钮配置迁移。

重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 16:21:53 +08:00
parent efb3925257
commit 6336feff27
49 changed files with 1996 additions and 590 deletions

View File

@ -15,6 +15,7 @@ final class ScenicQueueSettingChangeLogViewModel {
private(set) var isRefreshing = false
private(set) var isLoadingMore = false
private(set) var canLoadMore = false
private(set) var initialLoading = true
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@ -50,6 +51,7 @@ final class ScenicQueueSettingChangeLogViewModel {
}
notifyStateChange()
defer {
initialLoading = false
if append { isLoadingMore = false } else { isRefreshing = false }
notifyStateChange()
}

View File

@ -47,6 +47,7 @@ final class ScenicQueueSettingsViewModel {
private(set) var presetVoices: [String]
private(set) var settingsTab: ScenicQueueSettingsTab = .basic
private(set) var highlightedInvalidFieldKeys: Set<String> = []
private(set) var blockingInvalidNumericFieldKeys: Set<String> = []
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
@ -56,7 +57,7 @@ final class ScenicQueueSettingsViewModel {
private let appStore: AppStore
private let ttsManager: ScenicQueueTTSManaging
private var punchSpotSettingRequestGeneration = 0
private var ttsLoopAnchorText: String?
private(set) var ttsLoopAnchorText: String?
init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) {
self.appStore = appStore
@ -108,6 +109,30 @@ final class ScenicQueueSettingsViewModel {
appStore.scenicQueue.useOfflineTTS
}
///
var isCustomTTSLooping: Bool {
ttsManager.customTextLooping
}
/// Android 使
nonisolated static func kilometersDisplay(fromMeters meters: Int) -> String {
let value = Double(max(0, meters)) / 1_000
if value.rounded() == value { return String(format: "%.0f", value) }
let text = String(format: "%.2f", value)
return text.replacingOccurrences(of: #"0+$"#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"\.$"#, with: "", options: .regularExpression)
}
/// nil
nonisolated static func meters(fromKilometersText raw: String) -> Int? {
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.range(of: #"^\d{1,4}(\.\d{0,2})?$"#, options: .regularExpression) != nil,
let value = Decimal(string: text, locale: Locale(identifier: "en_US_POSIX")) else { return nil }
let meters = NSDecimalNumber(decimal: value * 1_000).intValue
guard Limits.queueDistance.contains(meters) else { return nil }
return meters
}
func openSettingChangeLog() {
onNavigateLog?()
}
@ -146,6 +171,18 @@ final class ScenicQueueSettingsViewModel {
notifyStateChange()
}
///
func reportOutOfRangeNumericPending(fieldKey: String, pending: Bool) {
if pending {
blockingInvalidNumericFieldKeys.insert(fieldKey)
highlightedInvalidFieldKeys.insert(fieldKey)
} else {
blockingInvalidNumericFieldKeys.remove(fieldKey)
highlightedInvalidFieldKeys.remove(fieldKey)
}
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() }
@ -222,6 +259,21 @@ final class ScenicQueueSettingsViewModel {
}
}
///
func startPresetVoiceLoop(_ phrase: String) {
let text = String(phrase.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Limits.remarkMaxLen))
guard !text.isEmpty else { return }
Task {
if ttsManager.customTextLooping {
ttsManager.stopCustomTextLoop()
}
let ok = await ttsManager.startCustomTextLoop(text)
ttsLoopAnchorText = ok ? text : nil
if !ok { onShowMessage?("播报失败,请检查网络与语音服务") }
notifyStateChange()
}
}
///
func playBluetoothTestSound() {
Task {
@ -289,7 +341,7 @@ final class ScenicQueueSettingsViewModel {
///
func save(api: ScenicQueueAPIProtocol) async {
highlightedInvalidFieldKeys = []
highlightedInvalidFieldKeys = blockingInvalidNumericFieldKeys
guard let ids = currentScenicAndSpotIdsForSelected() else {
highlightedInvalidFieldKeys = ["punchSpot"]
settingsTab = .basic
@ -297,6 +349,12 @@ final class ScenicQueueSettingsViewModel {
notifyStateChange()
return
}
guard blockingInvalidNumericFieldKeys.isEmpty else {
settingsTab = tabForValidationKeys(blockingInvalidNumericFieldKeys)
onShowMessage?("请修正标红的配置项")
notifyStateChange()
return
}
let validation = buildSaveValidation()
guard validation.messages.isEmpty else {
highlightedInvalidFieldKeys = validation.keys