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

重构排队设置与变更日志交互,补充 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

@ -28,6 +28,20 @@ struct ScenicQueueHomeData: Decodable, Equatable {
/// home stats
struct ScenicQueueHomeStats: Decodable, Equatable {
let type: Int
/// stats Android DTO type 使 0
init(type: Int = 0) {
self.type = type
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0
}
private enum CodingKeys: String, CodingKey {
case type
}
}
/// home
@ -426,6 +440,43 @@ struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Hashable {
}
}
extension ScenicQueueSettingChangeLogItem {
/// Android
var metadataLine: String {
let time: String
if let createdAt, !createdAt.isEmpty {
let input = DateFormatter()
input.locale = Locale(identifier: "en_US_POSIX")
input.timeZone = TimeZone(identifier: "Asia/Shanghai")
let formats = ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH:mm:ssXXXXX", "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"]
let parsedDate = formats.lazy.compactMap { format -> Date? in
input.dateFormat = format
return input.date(from: createdAt)
}.first
if let date = parsedDate {
let output = DateFormatter()
output.locale = Locale(identifier: "en_US_POSIX")
output.timeZone = TimeZone(identifier: "Asia/Shanghai")
output.dateFormat = "yyyy-MM-dd HH:mm"
time = output.string(from: date)
} else {
time = String(createdAt.replacingOccurrences(of: "T", with: " ").prefix(16))
}
} else {
time = ""
}
let name = operatorName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "未知" : operatorName.trimmingCharacters(in: .whitespacesAndNewlines)
let tail = operatorPhoneTail.trimmingCharacters(in: .whitespacesAndNewlines)
return tail.isEmpty ? "\(time) \(name)" : "\(time) \(name) 尾号\(tail)"
}
/// 使 summary退 displayText
var cardSummary: String {
let value = summary.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? displayText : value
}
}
///
struct ScenicSpotListResponse: Decodable, Equatable {
let list: [ScenicSpotItem]

View File

@ -6,11 +6,56 @@
import Foundation
///
enum ScenicQueueCallStatus: Equatable {
enum ScenicQueueCallStatus: Hashable {
case waiting
case called
}
///
struct ScenicQueueTicketActionState: Equatable {
let skipEnabled: Bool
let primaryVisible: Bool
let primaryEnabled: Bool
let primaryTitle: String
let recallVisible: Bool
let completeVisible: Bool
/// Android
static func make(
ticket: ScenicQueueTicket,
shootingQueueNo: String?,
remainSeconds: Int,
showStartShootingButton: Bool
) -> ScenicQueueTicketActionState {
let isShooting = shootingQueueNo == ticket.queueNo
if isShooting {
return ScenicQueueTicketActionState(
skipEnabled: false,
primaryVisible: true,
primaryEnabled: false,
primaryTitle: "剩余 \(max(0, remainSeconds))",
recallVisible: false,
completeVisible: ticket.showCompleteAction
)
}
let primaryVisible = ticket.status == .waiting || showStartShootingButton
return ScenicQueueTicketActionState(
skipEnabled: true,
primaryVisible: primaryVisible,
primaryEnabled: true,
primaryTitle: ticket.status == .waiting ? "叫号" : "开始拍摄",
recallVisible: ticket.status == .called,
completeVisible: ticket.showCompleteAction
)
}
}
/// Diffable Data Source
enum ScenicQueueListItem: Hashable {
case current(ScenicQueueTicket)
case skipped(ScenicQueueSkippedTicket)
}
///
struct ScenicQueueTicket: Hashable {
let recordId: Int64

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

View File

@ -194,13 +194,11 @@ final class WildPhotographerReportSubmitViewModel {
/// 500
func updateDescription(_ newValue: String) {
description = String(newValue.prefix(500))
notifyStateChange()
}
///
func updateContact(_ value: String) {
contact = String(value.prefix(80))
notifyStateChange()
}
///