从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。
|
||||
@MainActor
|
||||
@ -26,10 +26,9 @@ protocol ScenicQueueServing: AnyObject {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队管理 API,封装 `/api/app/scenic-queue` 下的排队接口。
|
||||
final class ScenicQueueAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化排队管理 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,44 +6,43 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import Photos
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队管理列表 ViewModel,负责打卡点门禁、列表分页、动作和实时事件。
|
||||
final class QueueManagementViewModel {
|
||||
var loading = false
|
||||
var loadingMore = false
|
||||
var items: [QueueItem] = []
|
||||
var scenicSpots: [ScenicSpotItem] = []
|
||||
var selectedSpotId: Int?
|
||||
var selectedListType: QueueListType = .queueing
|
||||
var queueCount = 0
|
||||
var avgWaitMin = 0.0
|
||||
var totalCount = 0
|
||||
var hasMore = false
|
||||
var page = 1
|
||||
var lastSyncTimeText = "--"
|
||||
var queueGatePassed = false
|
||||
var selectedSpotName = "--"
|
||||
var showStartShootingButton = true
|
||||
var autoCallAheadCount = 0
|
||||
var quickCallButtonEnabled = false
|
||||
var prepareCallButtonEnabled = false
|
||||
var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement?
|
||||
var errorMessage: String?
|
||||
final class QueueManagementViewModel: ObservableObject {
|
||||
@Published var loading = false
|
||||
@Published var loadingMore = false
|
||||
@Published var items: [QueueItem] = []
|
||||
@Published var scenicSpots: [ScenicSpotItem] = []
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published var selectedListType: QueueListType = .queueing
|
||||
@Published var queueCount = 0
|
||||
@Published var avgWaitMin = 0.0
|
||||
@Published var totalCount = 0
|
||||
@Published var hasMore = false
|
||||
@Published var page = 1
|
||||
@Published var lastSyncTimeText = "--"
|
||||
@Published var queueGatePassed = false
|
||||
@Published var selectedSpotName = "--"
|
||||
@Published var showStartShootingButton = true
|
||||
@Published var autoCallAheadCount = 0
|
||||
@Published var quickCallButtonEnabled = false
|
||||
@Published var prepareCallButtonEnabled = false
|
||||
@Published var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let pageSize = 10
|
||||
@ObservationIgnored private let socketClient = ScenicQueueSocketClient()
|
||||
@ObservationIgnored private var realtimeSpotId: Int?
|
||||
@ObservationIgnored private var realtimeScenicId: Int?
|
||||
@ObservationIgnored private var currentUserId: String?
|
||||
@ObservationIgnored private var currentScenicId: Int?
|
||||
@ObservationIgnored private var pendingRemoteCalledRecordIds = Set<Int64>()
|
||||
@ObservationIgnored private var handledRemoteCallEventIds = Set<String>()
|
||||
private let pageSize = 10
|
||||
private let socketClient = ScenicQueueSocketClient()
|
||||
@Published private var realtimeSpotId: Int?
|
||||
@Published private var realtimeScenicId: Int?
|
||||
@Published private var currentUserId: String?
|
||||
@Published private var currentScenicId: Int?
|
||||
@Published private var pendingRemoteCalledRecordIds = Set<Int64>()
|
||||
@Published private var handledRemoteCallEventIds = Set<String>()
|
||||
|
||||
/// 待处理排队数量。
|
||||
var pendingCount: Int {
|
||||
@ -417,41 +416,40 @@ final class QueueManagementViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队设置 ViewModel,负责设置读取、校验、保存、二维码和日志。
|
||||
final class ScenicQueueSettingsViewModel {
|
||||
var loading = false
|
||||
var message: String?
|
||||
var scenicSpots: [ScenicSpotItem] = []
|
||||
var selectedSpotId: Int?
|
||||
var photoEstimateMin = "0"
|
||||
var photoEstimateSec = "30"
|
||||
var firstAhead = "0"
|
||||
var firstSms = false
|
||||
var firstPhone = false
|
||||
var secondAhead = "0"
|
||||
var secondSms = false
|
||||
var secondPhone = false
|
||||
var broadcastIntervalSec = "50"
|
||||
var countdownThresholdSec = "15"
|
||||
var maxQueueRangeKm = "0.00"
|
||||
var localQueueLimit = "0"
|
||||
var localPassDelay = "1"
|
||||
var showStartShootingButton = true
|
||||
var autoCallAheadCount = "0"
|
||||
var queueOpen = true
|
||||
var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0)
|
||||
var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0)
|
||||
var customTtsText = ""
|
||||
var presetVoices: [String] = []
|
||||
var quickCallButtonEnabled = false
|
||||
var prepareCallButtonEnabled = false
|
||||
var logs: [ScenicQueueSettingChangeLogItem] = []
|
||||
var qrcodeURL = ""
|
||||
final class ScenicQueueSettingsViewModel: ObservableObject {
|
||||
@Published var loading = false
|
||||
@Published var message: String?
|
||||
@Published var scenicSpots: [ScenicSpotItem] = []
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published var photoEstimateMin = "0"
|
||||
@Published var photoEstimateSec = "30"
|
||||
@Published var firstAhead = "0"
|
||||
@Published var firstSms = false
|
||||
@Published var firstPhone = false
|
||||
@Published var secondAhead = "0"
|
||||
@Published var secondSms = false
|
||||
@Published var secondPhone = false
|
||||
@Published var broadcastIntervalSec = "50"
|
||||
@Published var countdownThresholdSec = "15"
|
||||
@Published var maxQueueRangeKm = "0.00"
|
||||
@Published var localQueueLimit = "0"
|
||||
@Published var localPassDelay = "1"
|
||||
@Published var showStartShootingButton = true
|
||||
@Published var autoCallAheadCount = "0"
|
||||
@Published var queueOpen = true
|
||||
@Published var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0)
|
||||
@Published var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0)
|
||||
@Published var customTtsText = ""
|
||||
@Published var presetVoices: [String] = []
|
||||
@Published var quickCallButtonEnabled = false
|
||||
@Published var prepareCallButtonEnabled = false
|
||||
@Published var logs: [ScenicQueueSettingChangeLogItem] = []
|
||||
@Published var qrcodeURL = ""
|
||||
|
||||
@ObservationIgnored private let speaker = ScenicQueueSpeechService()
|
||||
@ObservationIgnored private var currentUserId: String?
|
||||
@ObservationIgnored private var currentScenicId: Int?
|
||||
private let speaker = ScenicQueueSpeechService()
|
||||
@Published private var currentUserId: String?
|
||||
@Published private var currentScenicId: Int?
|
||||
|
||||
/// 加载设置。设置页允许自动选中第一个打卡点。
|
||||
func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {
|
||||
|
||||
@ -9,12 +9,12 @@ import SwiftUI
|
||||
|
||||
/// 排队管理首页,展示当前打卡点队列和操作入口。
|
||||
struct QueueManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ScenicQueueAPI.self) private var queueAPI
|
||||
@Environment(ScenicQueueRuntime.self) private var queueRuntime
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = QueueManagementViewModel()
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.scenicQueueAPI) private var queueAPI
|
||||
@EnvironmentObject private var queueRuntime: ScenicQueueRuntime
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = QueueManagementViewModel()
|
||||
@State private var pendingAction: QueueActionConfirmation?
|
||||
@State private var markTarget: QueueItem?
|
||||
@State private var processingId: Int64?
|
||||
@ -27,7 +27,7 @@ struct QueueManagementView: View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
header
|
||||
if currentScenicId == nil {
|
||||
ContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文"))
|
||||
AppContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文"))
|
||||
.frame(minHeight: 260)
|
||||
} else if !viewModel.queueGatePassed {
|
||||
gateCard
|
||||
@ -79,12 +79,12 @@ struct QueueManagementView: View {
|
||||
.refreshable {
|
||||
await reload()
|
||||
}
|
||||
.onChange(of: viewModel.remoteCallAnnouncement) { _, announcement in
|
||||
.onChange(of: viewModel.remoteCallAnnouncement) { announcement in
|
||||
if let announcement {
|
||||
toastCenter.show("远端已叫号 \(announcement.queueCode.isEmpty ? "当前游客" : announcement.queueCode)")
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message { toastCenter.show(message) }
|
||||
}
|
||||
}
|
||||
@ -163,7 +163,7 @@ struct QueueManagementView: View {
|
||||
ProgressView("加载中...")
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
} else if viewModel.items.isEmpty {
|
||||
ContentUnavailableView(viewModel.selectedListType.emptyText, systemImage: "person.crop.circle.badge.questionmark")
|
||||
AppContentUnavailableView(viewModel.selectedListType.emptyText, systemImage: "person.crop.circle.badge.questionmark")
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
} else {
|
||||
|
||||
@ -9,12 +9,12 @@ import SwiftUI
|
||||
|
||||
/// 排队设置页面。
|
||||
struct ScenicQueueSettingsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ScenicQueueAPI.self) private var queueAPI
|
||||
@Environment(ScenicQueueRuntime.self) private var queueRuntime
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = ScenicQueueSettingsViewModel()
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.scenicQueueAPI) private var queueAPI
|
||||
@EnvironmentObject private var queueRuntime: ScenicQueueRuntime
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = ScenicQueueSettingsViewModel()
|
||||
@State private var selectedTab: ScenicQueueSettingsTab = .basic
|
||||
@State private var qrcodeURL: String?
|
||||
@State private var saving = false
|
||||
@ -75,7 +75,7 @@ struct ScenicQueueSettingsView: View {
|
||||
await viewModel.load(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, spots: scenicSpotContext.spots)
|
||||
await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId)
|
||||
}
|
||||
.onChange(of: viewModel.message) { _, message in
|
||||
.onChange(of: viewModel.message) { message in
|
||||
if let message { toastCenter.show(message) }
|
||||
}
|
||||
}
|
||||
@ -106,7 +106,7 @@ struct ScenicQueueSettingsView: View {
|
||||
DatePicker("营业开始", selection: $viewModel.businessStartTime, displayedComponents: .hourAndMinute)
|
||||
DatePicker("营业结束", selection: $viewModel.businessEndTime, displayedComponents: .hourAndMinute)
|
||||
field("最大排队范围", text: $viewModel.maxQueueRangeKm, keyboard: .decimalPad, unit: "公里")
|
||||
.onChange(of: viewModel.maxQueueRangeKm) { _, value in viewModel.updateMaxQueueRange(value) }
|
||||
.onChange(of: viewModel.maxQueueRangeKm) { value in viewModel.updateMaxQueueRange(value) }
|
||||
field("排队次数限制", text: $viewModel.localQueueLimit, keyboard: .numberPad, unit: "次")
|
||||
field("过号顺延", text: $viewModel.localPassDelay, keyboard: .numberPad, unit: "位")
|
||||
field("拍摄分钟", text: $viewModel.photoEstimateMin, keyboard: .numberPad, unit: "分")
|
||||
@ -192,7 +192,7 @@ struct ScenicQueueSettingsView: View {
|
||||
}
|
||||
}
|
||||
if viewModel.logs.isEmpty {
|
||||
ContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath")
|
||||
AppContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath")
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
} else {
|
||||
ForEach(viewModel.logs) { log in
|
||||
|
||||
Reference in New Issue
Block a user