Migrate from iOS 17 Observation to iOS 16-compatible Combine architecture.

Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 10:16:35 +08:00
parent c32a610ee0
commit 26f4d0e671
127 changed files with 2320 additions and 1465 deletions

View File

@ -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) {

View File

@ -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 {

View File

@ -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 {

View File

@ -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