完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/// 添加本地现场图片。
|
||||
|
||||
Reference in New Issue
Block a user