From 703078352cd8320a23ecfcb2afc2aa52ff72555f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 26 Jun 2026 10:16:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=8E=20iOS=2017=20Observation=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=87=B3=20iOS=2016=20=E5=85=BC=E5=AE=B9=E7=9A=84=20C?= =?UTF-8?q?ombine=20=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor --- iOS16兼容迁移记录.md | 337 ++++++++++++++++++ suixinkan.xcodeproj/project.pbxproj | 150 +++++++- .../xcshareddata/swiftpm/Package.resolved | 11 +- .../xcshareddata/xcschemes/suixinkan.xcscheme | 24 ++ suixinkan/App/AppServiceEnvironment.swift | 261 ++++++++++++++ suixinkan/App/AppUITestLaunchState.swift | 22 ++ .../Navigation/NavigationCompatibility.swift | 31 ++ .../App/Navigation/NavigationRouter.swift | 18 +- suixinkan/App/RootView.swift | 88 ++--- suixinkan/App/State/AccountContext.swift | 15 +- suixinkan/App/State/AppSession.swift | 9 +- .../App/State/AuthSessionCoordinator.swift | 9 +- suixinkan/App/State/PermissionContext.swift | 11 +- suixinkan/App/State/ScenicSpotContext.swift | 11 +- suixinkan/App/State/SessionBootstrapper.swift | 7 +- suixinkan/App/State/ToastCenter.swift | 15 +- suixinkan/App/suixinkanApp.swift | 4 + .../Design/AppContentUnavailableView.swift | 75 ++++ .../Core/Design/GlobalLoadingCenter.swift | 2 +- .../Location/ForegroundLocationProvider.swift | 11 +- suixinkan/Core/Networking/APIClient.swift | 11 +- suixinkan/Core/Push/PushAPI.swift | 5 +- suixinkan/Core/Queue/ScenicQueueRuntime.swift | 37 +- .../Core/Storage/AccountSnapshotStore.swift | 11 +- .../Core/Storage/AppPreferencesStore.swift | 15 +- .../Core/Storage/SessionTokenStore.swift | 7 +- suixinkan/Core/UI/RemoteImage.swift | 2 +- suixinkan/Core/Upload/OSSUploadService.swift | 5 +- suixinkan/Core/Upload/UploadAPI.swift | 5 +- .../Account/API/AccountContextAPI.swift | 5 +- suixinkan/Features/Assets/API/AssetsAPI.swift | 5 +- .../Assets/ViewModels/AssetsViewModels.swift | 147 ++++---- .../Features/Assets/Views/AlbumViews.swift | 32 +- .../Assets/Views/CloudStorageViews.swift | 18 +- .../Assets/Views/MediaLibraryViews.swift | 36 +- suixinkan/Features/Auth/API/AuthAPI.swift | 5 +- .../Auth/ViewModels/LoginViewModel.swift | 21 +- .../Auth/Views/AccountSelectionView.swift | 2 +- suixinkan/Features/Auth/Views/LoginView.swift | 38 +- .../Home/ViewModels/HomeViewModel.swift | 7 +- .../Home/Views/HomeMoreFunctionsView.swift | 12 +- suixinkan/Features/Home/Views/HomeView.swift | 22 +- suixinkan/Features/Invite/API/InviteAPI.swift | 5 +- .../Invite/ViewModels/InviteViewModels.swift | 48 ++- .../Features/Invite/Views/InviteViews.swift | 16 +- suixinkan/Features/Live/API/LiveAPI.swift | 5 +- .../ViewModels/LivePlaybackViewModel.swift | 11 +- .../LivePushReadinessViewModel.swift | 25 +- .../Live/ViewModels/LiveViewModels.swift | 83 ++--- .../Features/Live/Views/LiveAlbumViews.swift | 40 +-- .../Live/Views/LiveManagementViews.swift | 30 +- .../API/LocationReportAPI.swift | 5 +- .../ViewModels/LocationReportViewModels.swift | 46 ++- .../Views/LocationReportViews.swift | 22 +- .../ViewModels/MainTabBadgeViewModel.swift | 7 +- .../Features/Main/Views/MainTabsView.swift | 38 +- .../Main/Views/PlaceholderRootViews.swift | 2 +- .../MessageCenter/API/MessageCenterAPI.swift | 5 +- .../ViewModels/MessageCenterViewModel.swift | 23 +- .../Views/MessageCenterViews.swift | 10 +- .../OperatingArea/API/OperatingAreaAPI.swift | 5 +- .../ViewModels/OperatingAreaViewModel.swift | 17 +- .../Views/OperatingAreaViews.swift | 12 +- suixinkan/Features/Orders/API/OrdersAPI.swift | 5 +- .../ViewModels/OrderDetailViewModel.swift | 13 +- .../ViewModels/OrderLongTailViewModels.swift | 88 +++-- .../Orders/ViewModels/OrdersViewModel.swift | 39 +- .../Orders/Views/DepositOrderViews.swift | 42 +-- .../Orders/Views/OrderDetailViews.swift | 22 +- .../Orders/Views/OrderTailUploadViews.swift | 14 +- .../Features/Orders/Views/OrdersView.swift | 40 +-- .../Features/Payment/API/PaymentAPI.swift | 5 +- .../Payment/ViewModels/PaymentViewModel.swift | 36 +- .../Payment/Views/PaymentCollectionView.swift | 25 +- .../API/PilotCertificationAPI.swift | 5 +- .../PilotCertificationViewModel.swift | 45 ++- .../Views/PilotCertificationViews.swift | 14 +- .../Features/Profile/API/ProfileAPI.swift | 5 +- .../ViewModels/AccountSwitchViewModel.swift | 15 +- .../Profile/ViewModels/ProfileViewModel.swift | 23 +- .../ViewModels/RealNameAuthViewModel.swift | 43 ++- .../Profile/Views/AccountSwitchView.swift | 22 +- .../Views/DebugHomeMenuPreviewView.swift | 6 +- .../Features/Profile/Views/ProfileView.swift | 44 +-- .../Profile/Views/RealNameAuthView.swift | 14 +- .../Profile/Views/SettingsFlowViews.swift | 2 +- .../Features/Projects/API/ProjectAPI.swift | 5 +- .../ViewModels/ProjectViewModels.swift | 188 +++++----- .../Projects/Views/ProjectViews.swift | 56 +-- .../PunchPoint/API/PunchPointAPI.swift | 5 +- .../ViewModels/PunchPointViewModels.swift | 67 ++-- .../PunchPoint/Views/PunchPointViews.swift | 50 +-- .../QueueManagement/API/ScenicQueueAPI.swift | 5 +- .../QueueManagementViewModels.swift | 126 ++++--- .../Views/QueueManagementViews.swift | 20 +- .../Views/ScenicQueueSettingsViews.swift | 18 +- .../API/ScenicPermissionAPI.swift | 5 +- .../ViewModels/PermissionApplyViewModel.swift | 36 +- .../ScenicApplicationViewModel.swift | 41 ++- .../ViewModels/ScenicSelectionViewModel.swift | 13 +- .../Views/PermissionApplyStatusView.swift | 10 +- .../Views/PermissionApplyView.swift | 8 +- .../Views/ScenicApplicationView.swift | 12 +- .../Views/ScenicSelectionView.swift | 10 +- .../API/ScenicSettlementAPI.swift | 5 +- .../ScenicSettlementViewModels.swift | 42 ++- .../Views/ScenicSettlementViews.swift | 24 +- .../Features/Schedule/API/ScheduleAPI.swift | 5 +- .../ViewModels/ScheduleViewModels.swift | 30 +- .../Schedule/Views/ScheduleViews.swift | 16 +- .../Statistics/API/StatisticsAPI.swift | 5 +- .../ViewModels/StatisticsViewModel.swift | 21 +- .../Statistics/Views/StatisticsView.swift | 26 +- suixinkan/Features/Tasks/API/TaskAPI.swift | 5 +- .../TaskCloudFileSelectionViewModel.swift | 25 +- .../ViewModels/TaskCreateViewModel.swift | 27 +- .../ViewModels/TaskManagementViewModel.swift | 34 +- .../Features/Tasks/Views/TaskCreateView.swift | 12 +- .../Features/Tasks/Views/TaskDetailView.swift | 6 +- .../Tasks/Views/TaskManagementView.swift | 12 +- .../Tasks/Views/TaskSelectionViews.swift | 8 +- suixinkan/Features/Wallet/API/WalletAPI.swift | 5 +- .../Wallet/ViewModels/WalletViewModels.swift | 110 +++--- .../Features/Wallet/Views/WalletView.swift | 48 +-- .../ViewModels/WithdrawalAuditViewModel.swift | 23 +- .../Views/WithdrawalAuditView.swift | 12 +- suixinkanUITests/LoginSmokeUITests.swift | 28 ++ 127 files changed, 2320 insertions(+), 1465 deletions(-) create mode 100644 iOS16兼容迁移记录.md create mode 100644 suixinkan/App/AppServiceEnvironment.swift create mode 100644 suixinkan/App/AppUITestLaunchState.swift create mode 100644 suixinkan/App/Navigation/NavigationCompatibility.swift create mode 100644 suixinkan/Core/Design/AppContentUnavailableView.swift create mode 100644 suixinkanUITests/LoginSmokeUITests.swift diff --git a/iOS16兼容迁移记录.md b/iOS16兼容迁移记录.md new file mode 100644 index 0000000..a922199 --- /dev/null +++ b/iOS16兼容迁移记录.md @@ -0,0 +1,337 @@ +# iOS 16 兼容迁移记录 + +记录日期:2026-06-26 +工程:`suixinkan_ios_new` +目标:将新 iOS 工程从 iOS 17 Observation 架构迁到 iOS 16 可运行的 Combine/SwiftUI 架构,同时保留 iOS 17+ 新 API 的局部增强能力。 + +## 背景 + +本次迁移前,工程大量使用 iOS 17 的 Observation 体系: + +- `@Observable` +- `@Bindable` +- `@Environment(Type.self)` +- `.environment(object)` +- 双参数 `.onChange(of:) { oldValue, newValue in ... }` +- iOS 17-only SwiftUI API,例如 `ContentUnavailableView`、`navigationDestination(item:)` + +为了让 App 最低支持 iOS 16.0,统一迁移到 iOS 16 可用的 `ObservableObject + @Published + EnvironmentObject/EnvironmentKey` 体系。没有维护两套 RootView,也没有做运行时双架构分支。 + +## 本次完成的改动 + +### 1. Deployment Target + +文件: + +- `suixinkan.xcodeproj/project.pbxproj` + +改动: + +- App target Debug/Release:`IPHONEOS_DEPLOYMENT_TARGET = 16` +- Test target Debug/Release:`IPHONEOS_DEPLOYMENT_TARGET = 16` + +说明: + +- 项目级 SDK 行里仍可看到 `26.5`,这是当前 Xcode/SDK 生成配置,不等于 app/test target 的最低系统版本。 + +### 2. 状态体系:Observation -> Combine + +迁移规则: + +- `@Observable class Xxx` -> `final class Xxx: ObservableObject` +- 会驱动 UI 的字段 -> `@Published var` +- 不驱动 UI 的依赖、client、常量、配置 -> 普通属性 +- View 持有 ViewModel: + - `@State private var viewModel = XxxViewModel()` -> `@StateObject private var viewModel = XxxViewModel()` + - 子视图接收 ViewModel -> `@ObservedObject` +- 移除 `@Bindable` + +典型文件: + +- `suixinkan/App/State/AppSession.swift` +- `suixinkan/App/State/AccountContext.swift` +- `suixinkan/App/State/PermissionContext.swift` +- `suixinkan/App/State/ScenicSpotContext.swift` +- `suixinkan/App/State/ToastCenter.swift` +- `suixinkan/App/Navigation/NavigationRouter.swift` +- 各业务 `Features/*/ViewModels/*.swift` + +保留为 `ObservableObject` 的对象主要是 UI 状态或运行时状态: + +- `AppSession` +- `AccountContext` +- `PermissionContext` +- `ScenicSpotContext` +- `AppRouter` +- `RouterPath` +- `ToastCenter` +- `ScenicQueueRuntime` +- `ForegroundLocationProvider` +- 各业务 ViewModel + +已清回普通 class 的对象: + +- `APIClient` +- 各业务 `*API` +- `OSSUploadService` +- `UploadAPI` +- token/snapshot/preferences store +- `AuthSessionCoordinator` +- `SessionBootstrapper` +- `PushAPI` + +### 3. 环境注入迁移 + +新增文件: + +- `suixinkan/App/AppServiceEnvironment.swift` + +状态对象使用: + +```swift +.environmentObject(appSession) +.environmentObject(accountContext) +.environmentObject(permissionContext) +.environmentObject(scenicSpotContext) +.environmentObject(appRouter) +.environmentObject(toastCenter) +.environmentObject(scenicQueueRuntime) +``` + +页面读取: + +```swift +@EnvironmentObject private var appSession: AppSession +@EnvironmentObject private var router: RouterPath +@EnvironmentObject private var toastCenter: ToastCenter +``` + +服务对象使用自定义 `EnvironmentKey`: + +```swift +.environment(\.ordersAPI, ordersAPI) +.environment(\.profileAPI, profileAPI) +.environment(\.ossUploadService, ossUploadService) +``` + +页面读取: + +```swift +@Environment(\.ordersAPI) private var ordersAPI +@Environment(\.ossUploadService) private var uploadService +``` + +注意: + +- `AppServiceEnvironment.swift` 里的 key 提供默认实例,避免 SwiftUI/test runner 在环境装配阶段读取 key 时崩溃。 +- RootView 仍会注入真实共享实例,默认实例主要用于 preview/test fallback。 + +### 4. RootView 和 MainTabsView + +核心文件: + +- `suixinkan/App/RootView.swift` +- `suixinkan/Features/Main/Views/MainTabsView.swift` + +主要变化: + +- `RootView` 中全局 UI 状态改为 `@StateObject` +- service/API 继续作为稳定引用由 RootView 创建并下发 +- `MainTabsView` 中 `AppRouter` 改为 `@EnvironmentObject` +- 每个 tab 的 `RouterPath` 用 `.environmentObject(appRouter.router(for: tab))` 注入 +- `TabView(selection:)` 和自定义 tab 绑定改用 `$appRouter.selectedTab` + +### 5. iOS 16 SwiftUI 兼容 + +新增文件: + +- `suixinkan/Core/Design/AppContentUnavailableView.swift` +- `suixinkan/App/Navigation/NavigationCompatibility.swift` + +兼容点: + +- `ContentUnavailableView` -> `AppContentUnavailableView` +- `navigationDestination(item:)` -> `appNavigationDestination(item:)` +- 双参数 `.onChange(of:)` -> iOS 16 可用的单参数形式 + +`AppContentUnavailableView` 行为: + +- iOS 17+:内部走系统 `ContentUnavailableView` +- iOS 16:降级为项目风格的 VStack 空状态 UI + +`appNavigationDestination(item:)` 行为: + +- 使用 iOS 16 可用的 `navigationDestination(isPresented:)` 包一层 optional item +- 保留原来 optional item push 的页面行为 + +### 6. StateObject 不能直接替换实例的修复 + +迁移到 `@StateObject` 后,不能再写: + +```swift +viewModel = SomeViewModel(...) +``` + +已改成 ViewModel 内部原地回填: + +- `ProjectEditorViewModel.apply(_:)` +- `StoreProjectEditorViewModel.apply(_:)` +- `PunchPointEditorViewModel.apply(_:)` + +涉及文件: + +- `suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift` +- `suixinkan/Features/Projects/Views/ProjectViews.swift` +- `suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift` +- `suixinkan/Features/PunchPoint/Views/PunchPointViews.swift` + +### 7. 推送迁移保留 + +本次 iOS 16 兼容迁移没有回退之前完成的 APNs 推送迁移。 + +相关文件仍保留: + +- `suixinkan/Core/Push/PushAPI.swift` +- `suixinkan/Core/Push/PushPayload.swift` +- `suixinkan/Core/Push/PushNotificationManager.swift` +- `suixinkan/App/AppDelegate.swift` +- `suixinkan/suixinkan.entitlements` +- `suixinkan/Info.plist` +- `suixinkanTests/PushNotificationTests.swift` + +## 验证命令 + +App build: + +```bash +/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild build \ + -quiet \ + -workspace suixinkan.xcworkspace \ + -scheme suixinkan \ + -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +全量测试: + +```bash +/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild test \ + -quiet \ + -workspace suixinkan.xcworkspace \ + -scheme suixinkan \ + -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +验证结果: + +- App build:通过 +- 全量 tests:通过 + +已知 warning: + +- Pods 里部分 AMap target 的 deployment target 为 9.0,当前 Xcode 提示支持范围为 12.0 到 26.5.99。 +- Pods dummy object 有 “built for newer iOS-simulator version 17.0 than being linked 16.0” 警告。 +- 若干 Swift 6 未来并发检查 warning,目前不影响 Swift 5 build/test。 + +## 以后从 iOS 16 迁回 iOS 17 的建议 + +如果未来确定最低版本重新升回 iOS 17,可以考虑反向迁移,但建议分阶段做。 + +### 阶段 1:先改工程配置 + +- App target Debug/Release:`IPHONEOS_DEPLOYMENT_TARGET = 17` +- Test target Debug/Release:`IPHONEOS_DEPLOYMENT_TARGET = 17` + +先跑: + +```bash +xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' +xcodebuild test -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +### 阶段 2:是否迁回 Observation + +不一定必须迁回。即使最低版本是 iOS 17,`ObservableObject + @Published` 仍然可以继续使用。 + +只有在明确想利用 Observation 的简化语法时,再迁回: + +- `ObservableObject` 状态/ViewModel -> `@Observable` +- `@Published` -> 普通 `var` +- `@StateObject` -> `@State` +- `@ObservedObject` -> 普通属性或 `@Bindable` +- `@EnvironmentObject` -> `@Environment(Type.self)` +- `.environmentObject(object)` -> `.environment(object)` + +建议优先迁回这些核心状态: + +- `AppSession` +- `AccountContext` +- `PermissionContext` +- `ScenicSpotContext` +- `AppRouter` +- `RouterPath` +- `ToastCenter` +- `ScenicQueueRuntime` + +然后再考虑业务 ViewModel。 + +### 阶段 3:服务注入可以不迁回 + +即使回到 iOS 17,也建议保留当前 `AppServiceEnvironment.swift` 的 service key 注入方式。 + +原因: + +- service/API 本质不是 UI 状态 +- 自定义 EnvironmentKey 更清晰地区分 service 与 observable state +- 对测试和 preview 更稳定 + +### 阶段 4:恢复 iOS 17 SwiftUI API + +可选恢复: + +- `AppContentUnavailableView(...)` -> `ContentUnavailableView(...)` +- `.appNavigationDestination(item:)` -> `.navigationDestination(item:)` +- 单参数 `.onChange(of:)` 可继续保留;iOS 17 兼容单参数版本 + +建议: + +- `AppContentUnavailableView` 可以继续保留,因为它内部 iOS 17+ 已经走系统 `ContentUnavailableView`。 +- `appNavigationDestination(item:)` 也可以继续保留,除非想彻底减少兼容层。 + +## 给未来 Codex 的提示词 + +如果未来要迁回 iOS 17,可以把下面这段发给 Codex: + +```text +请基于仓库中的 iOS16兼容迁移记录.md,帮我评估并执行从 iOS 16 兼容架构迁回 iOS 17 的改造。 + +要求: +1. 先检查当前代码和 git diff,不要假设记录完全最新。 +2. 先把 app/test target 的 IPHONEOS_DEPLOYMENT_TARGET 改到 17。 +3. 优先判断是否真的需要迁回 Observation;如果迁回,先迁核心状态对象,再迁业务 ViewModel。 +4. service/API/store 继续保持普通 class + EnvironmentKey 注入,除非有明确收益。 +5. 可以把 AppContentUnavailableView 和 appNavigationDestination 视情况恢复为 iOS 17 原生 API。 +6. 迁移完成后跑 xcodebuild build 和 xcodebuild test。 +7. 不要删除现有 APNs 推送迁移代码。 +``` + +## 快速检查命令 + +检查是否还有 iOS 17 Observation 残留语法: + +```bash +rg -n "import Observation|@Observable|@ObservationIgnored|@Environment\\([A-Za-z0-9_]+\\.self\\)|@Bindable|onChange\\(of:.*\\{\\s*_,|navigationDestination\\(item:" suixinkan --glob '*.swift' +``` + +检查 target deployment: + +```bash +rg -n "IPHONEOS_DEPLOYMENT_TARGET" suixinkan.xcodeproj/project.pbxproj +``` + +检查 service/API 是否误变成 ObservableObject: + +```bash +rg -n "class .*ObservableObject" suixinkan/Core suixinkan/App/State suixinkan/Features/*/API +``` + +正常情况下,service/API/store 不应出现在最后这个结果里;UI 状态对象和 ViewModel 可以是 `ObservableObject`。 diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index ff3575c..d168fb9 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -22,6 +22,13 @@ remoteGlobalIDString = 939AC7952FE3F832004B22E4; remoteInfo = suixinkan; }; + B00000072FEF000000000007 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 939AC78E2FE3F832004B22E4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 939AC7952FE3F832004B22E4; + remoteInfo = suixinkan; + }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -30,6 +37,7 @@ 93946BFAF78A845957AF2518 /* Pods-suixinkan.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan.debug.xcconfig"; path = "Target Support Files/Pods-suixinkan/Pods-suixinkan.debug.xcconfig"; sourceTree = ""; }; 939AC7962FE3F832004B22E4 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; A00000012FE9000000000001 /* suixinkanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B00000012FEF000000000001 /* suixinkanUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D763A7B62050979A1482C545 /* Pods-suixinkanTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkanTests.debug.xcconfig"; path = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.debug.xcconfig"; sourceTree = ""; }; EB50333A77B5E2F612A2F9BC /* Pods-suixinkanTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkanTests.release.xcconfig"; path = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.release.xcconfig"; sourceTree = ""; }; EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkanTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -59,6 +67,11 @@ path = suixinkanTests; sourceTree = ""; }; + B00000022FEF000000000002 /* suixinkanUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = suixinkanUITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -81,6 +94,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B00000052FEF000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -89,6 +109,7 @@ children = ( 939AC7982FE3F832004B22E4 /* suixinkan */, A00000022FE9000000000002 /* suixinkanTests */, + B00000022FEF000000000002 /* suixinkanUITests */, 939AC7972FE3F832004B22E4 /* Products */, C7EEB11765222926FB482A0E /* Pods */, F6ABC23A28D8DF8126F325ED /* Frameworks */, @@ -100,6 +121,7 @@ children = ( 939AC7962FE3F832004B22E4 /* suixinkan.app */, A00000012FE9000000000001 /* suixinkanTests.xctest */, + B00000012FEF000000000001 /* suixinkanUITests.xctest */, ); name = Products; sourceTree = ""; @@ -176,6 +198,27 @@ productReference = A00000012FE9000000000001 /* suixinkanTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + B00000032FEF000000000003 /* suixinkanUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = B000000B2FEF00000000000B /* Build configuration list for PBXNativeTarget "suixinkanUITests" */; + buildPhases = ( + B00000042FEF000000000004 /* Sources */, + B00000052FEF000000000005 /* Frameworks */, + B00000062FEF000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + B00000082FEF000000000008 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + B00000022FEF000000000002 /* suixinkanUITests */, + ); + name = suixinkanUITests; + productName = suixinkanUITests; + productReference = B00000012FEF000000000001 /* suixinkanUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -193,6 +236,10 @@ CreatedOnToolsVersion = 26.5; TestTargetID = 939AC7952FE3F832004B22E4; }; + B00000032FEF000000000003 = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 939AC7952FE3F832004B22E4; + }; }; }; buildConfigurationList = 939AC7912FE3F832004B22E4 /* Build configuration list for PBXProject "suixinkan" */; @@ -216,6 +263,7 @@ targets = ( 939AC7952FE3F832004B22E4 /* suixinkan */, A00000032FE9000000000003 /* suixinkanTests */, + B00000032FEF000000000003 /* suixinkanUITests */, ); }; /* End PBXProject section */ @@ -235,6 +283,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B00000062FEF000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -320,6 +375,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B00000042FEF000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -328,6 +390,11 @@ target = 939AC7952FE3F832004B22E4 /* suixinkan */; targetProxy = A00000072FE9000000000007 /* PBXContainerItemProxy */; }; + B00000082FEF000000000008 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 939AC7952FE3F832004B22E4 /* suixinkan */; + targetProxy = B00000072FEF000000000007 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -467,7 +534,7 @@ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = suixinkan/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -505,7 +572,7 @@ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = suixinkan/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 17; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -539,7 +606,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -575,7 +642,7 @@ ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17; + IPHONEOS_DEPLOYMENT_TARGET = 16; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -600,6 +667,72 @@ }; name = Release; }; + B00000092FEF000000000009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = suixinkan; + }; + name = Debug; + }; + B000000A2FEF00000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = suixinkan; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -630,6 +763,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B000000B2FEF00000000000B /* Build configuration list for PBXNativeTarget "suixinkanUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B00000092FEF000000000009 /* Debug */, + B000000A2FEF00000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5d4bc3f..6209188 100644 --- a/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "40830d6592be5c997cf1b8848d6a8791afd48ce9a5e48a1f297fbae4a6de3783", + "originHash" : "6f02892b99a60872df0561ceead8949f780d607eccd0a776d8f299248fc13a72", "pins" : [ { "identity" : "alibabacloud-oss-swift-sdk-v2", @@ -19,6 +19,15 @@ "version" : "8.10.0" } }, + { + "identity" : "lottie-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/airbnb/lottie-ios.git", + "state" : { + "revision" : "f4db77d7feacba0c2360b84a40c38a6ce8ff399d", + "version" : "4.6.1" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme b/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme index 21a566e..41e5031 100644 --- a/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme +++ b/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme @@ -35,6 +35,20 @@ ReferencedContainer = "container:suixinkan.xcodeproj"> + + + + + + + + APIClient { + APIClient() + } +} + +private struct APIClientEnvironmentKey: EnvironmentKey { + static var defaultValue: APIClient { EnvironmentServiceDefaults.apiClient() } +} + +private struct UploadAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: UploadAPI { UploadAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct PushAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: PushAPI { PushAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct AccountSnapshotStoreEnvironmentKey: EnvironmentKey { + static var defaultValue: AccountSnapshotStore { AccountSnapshotStore() } +} + +private struct AuthSessionCoordinatorEnvironmentKey: EnvironmentKey { + static var defaultValue: AuthSessionCoordinator { AuthSessionCoordinator() } +} + +private struct AccountContextAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: AccountContextAPI { AccountContextAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct AssetsAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: AssetsAPI { AssetsAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct AuthAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: AuthAPI { AuthAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct InviteAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: InviteAPI { InviteAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct LiveAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: LiveAPI { LiveAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct LocationReportAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: LocationReportAPI { LocationReportAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct MessageCenterAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: MessageCenterAPI { MessageCenterAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct OperatingAreaAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: OperatingAreaAPI { OperatingAreaAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct OrdersAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: OrdersAPI { OrdersAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct OSSUploadServiceEnvironmentKey: EnvironmentKey { + static var defaultValue: OSSUploadService { + OSSUploadService(configService: UploadAPI(client: EnvironmentServiceDefaults.apiClient())) + } +} + +private struct PaymentAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: PaymentAPI { PaymentAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct PilotCertificationAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: PilotCertificationAPI { PilotCertificationAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct ProfileAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: ProfileAPI { ProfileAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct ProjectAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: ProjectAPI { ProjectAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct PunchPointAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: PunchPointAPI { PunchPointAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct ScenicPermissionAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: ScenicPermissionAPI { ScenicPermissionAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct ScenicQueueAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: ScenicQueueAPI { ScenicQueueAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct ScenicSettlementAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: ScenicSettlementAPI { ScenicSettlementAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct ScheduleAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: ScheduleAPI { ScheduleAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct StatisticsAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: StatisticsAPI { StatisticsAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct TaskAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: TaskAPI { TaskAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +private struct WalletAPIEnvironmentKey: EnvironmentKey { + static var defaultValue: WalletAPI { WalletAPI(client: EnvironmentServiceDefaults.apiClient()) } +} + +extension EnvironmentValues { + var apiClient: APIClient { + get { self[APIClientEnvironmentKey.self] } + set { self[APIClientEnvironmentKey.self] = newValue } + } + + var uploadAPI: UploadAPI { + get { self[UploadAPIEnvironmentKey.self] } + set { self[UploadAPIEnvironmentKey.self] = newValue } + } + + var pushAPI: PushAPI { + get { self[PushAPIEnvironmentKey.self] } + set { self[PushAPIEnvironmentKey.self] = newValue } + } + + var accountSnapshotStore: AccountSnapshotStore { + get { self[AccountSnapshotStoreEnvironmentKey.self] } + set { self[AccountSnapshotStoreEnvironmentKey.self] = newValue } + } + + var authSessionCoordinator: AuthSessionCoordinator { + get { self[AuthSessionCoordinatorEnvironmentKey.self] } + set { self[AuthSessionCoordinatorEnvironmentKey.self] = newValue } + } + + var accountContextAPI: AccountContextAPI { + get { self[AccountContextAPIEnvironmentKey.self] } + set { self[AccountContextAPIEnvironmentKey.self] = newValue } + } + + var assetsAPI: AssetsAPI { + get { self[AssetsAPIEnvironmentKey.self] } + set { self[AssetsAPIEnvironmentKey.self] = newValue } + } + + var authAPI: AuthAPI { + get { self[AuthAPIEnvironmentKey.self] } + set { self[AuthAPIEnvironmentKey.self] = newValue } + } + + var inviteAPI: InviteAPI { + get { self[InviteAPIEnvironmentKey.self] } + set { self[InviteAPIEnvironmentKey.self] = newValue } + } + + var liveAPI: LiveAPI { + get { self[LiveAPIEnvironmentKey.self] } + set { self[LiveAPIEnvironmentKey.self] = newValue } + } + + var locationReportAPI: LocationReportAPI { + get { self[LocationReportAPIEnvironmentKey.self] } + set { self[LocationReportAPIEnvironmentKey.self] = newValue } + } + + var messageCenterAPI: MessageCenterAPI { + get { self[MessageCenterAPIEnvironmentKey.self] } + set { self[MessageCenterAPIEnvironmentKey.self] = newValue } + } + + var operatingAreaAPI: OperatingAreaAPI { + get { self[OperatingAreaAPIEnvironmentKey.self] } + set { self[OperatingAreaAPIEnvironmentKey.self] = newValue } + } + + var ordersAPI: OrdersAPI { + get { self[OrdersAPIEnvironmentKey.self] } + set { self[OrdersAPIEnvironmentKey.self] = newValue } + } + + var ossUploadService: OSSUploadService { + get { self[OSSUploadServiceEnvironmentKey.self] } + set { self[OSSUploadServiceEnvironmentKey.self] = newValue } + } + + var paymentAPI: PaymentAPI { + get { self[PaymentAPIEnvironmentKey.self] } + set { self[PaymentAPIEnvironmentKey.self] = newValue } + } + + var pilotCertificationAPI: PilotCertificationAPI { + get { self[PilotCertificationAPIEnvironmentKey.self] } + set { self[PilotCertificationAPIEnvironmentKey.self] = newValue } + } + + var profileAPI: ProfileAPI { + get { self[ProfileAPIEnvironmentKey.self] } + set { self[ProfileAPIEnvironmentKey.self] = newValue } + } + + var projectAPI: ProjectAPI { + get { self[ProjectAPIEnvironmentKey.self] } + set { self[ProjectAPIEnvironmentKey.self] = newValue } + } + + var punchPointAPI: PunchPointAPI { + get { self[PunchPointAPIEnvironmentKey.self] } + set { self[PunchPointAPIEnvironmentKey.self] = newValue } + } + + var scenicPermissionAPI: ScenicPermissionAPI { + get { self[ScenicPermissionAPIEnvironmentKey.self] } + set { self[ScenicPermissionAPIEnvironmentKey.self] = newValue } + } + + var scenicQueueAPI: ScenicQueueAPI { + get { self[ScenicQueueAPIEnvironmentKey.self] } + set { self[ScenicQueueAPIEnvironmentKey.self] = newValue } + } + + var scenicSettlementAPI: ScenicSettlementAPI { + get { self[ScenicSettlementAPIEnvironmentKey.self] } + set { self[ScenicSettlementAPIEnvironmentKey.self] = newValue } + } + + var scheduleAPI: ScheduleAPI { + get { self[ScheduleAPIEnvironmentKey.self] } + set { self[ScheduleAPIEnvironmentKey.self] = newValue } + } + + var statisticsAPI: StatisticsAPI { + get { self[StatisticsAPIEnvironmentKey.self] } + set { self[StatisticsAPIEnvironmentKey.self] = newValue } + } + + var taskAPI: TaskAPI { + get { self[TaskAPIEnvironmentKey.self] } + set { self[TaskAPIEnvironmentKey.self] = newValue } + } + + var walletAPI: WalletAPI { + get { self[WalletAPIEnvironmentKey.self] } + set { self[WalletAPIEnvironmentKey.self] = newValue } + } +} diff --git a/suixinkan/App/AppUITestLaunchState.swift b/suixinkan/App/AppUITestLaunchState.swift new file mode 100644 index 0000000..8c2e821 --- /dev/null +++ b/suixinkan/App/AppUITestLaunchState.swift @@ -0,0 +1,22 @@ +// +// AppUITestLaunchState.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import Foundation + +/// UI 测试启动状态清理,仅在测试进程显式传入启动参数时执行。 +enum AppUITestLaunchState { + static let resetArgument = "-suixinkan-ui-tests-reset-state" + + static func resetIfNeeded(arguments: [String] = ProcessInfo.processInfo.arguments) { + guard arguments.contains(resetArgument) else { return } + + try? SessionTokenStore().clear() + AccountSnapshotStore().clear() + let preferences = AppPreferencesStore() + preferences.clear() + } +} diff --git a/suixinkan/App/Navigation/NavigationCompatibility.swift b/suixinkan/App/Navigation/NavigationCompatibility.swift new file mode 100644 index 0000000..c57154e --- /dev/null +++ b/suixinkan/App/Navigation/NavigationCompatibility.swift @@ -0,0 +1,31 @@ +// +// NavigationCompatibility.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import SwiftUI + +extension View { + /// iOS 16 compatible replacement for iOS 17's navigationDestination(item:). + func appNavigationDestination( + item: Binding, + @ViewBuilder destination: @escaping (Item) -> Destination + ) -> some View { + navigationDestination( + isPresented: Binding( + get: { item.wrappedValue != nil }, + set: { isPresented in + if !isPresented { + item.wrappedValue = nil + } + } + ) + ) { + if let value = item.wrappedValue { + destination(value) + } + } + } +} diff --git a/suixinkan/App/Navigation/NavigationRouter.swift b/suixinkan/App/Navigation/NavigationRouter.swift index 0fa4115..ce7d7fc 100644 --- a/suixinkan/App/Navigation/NavigationRouter.swift +++ b/suixinkan/App/Navigation/NavigationRouter.swift @@ -5,7 +5,7 @@ // Created by Codex on 2026/6/18. // -import Observation +import Combine import SwiftUI /// 应用内可导航目标,承载各 Tab 内部的路由目的地。 @@ -60,10 +60,9 @@ extension OrdersRoute { } @MainActor -@Observable /// 单个 NavigationStack 的路径容器,用于保存某个 Tab 的导航历史。 -final class RouterPath { - var path: [AppRoute] = [] +final class RouterPath: ObservableObject { + @Published var path: [AppRoute] = [] /// 将指定路由压入当前 Tab 的导航栈。 func navigate(to route: AppRoute) { @@ -77,13 +76,12 @@ final class RouterPath { } @MainActor -@Observable /// 主导航状态中心,管理当前 Tab 和每个 Tab 独立的 NavigationStack 路径。 -final class AppRouter { - var selectedTab: AppTab = .home - var selectedOrdersEntry: OrdersEntry = .storeOrders - private(set) var pendingOrderScanCode: String? - private var routers: [AppTab: RouterPath] = [:] +final class AppRouter: ObservableObject { + @Published var selectedTab: AppTab = .home + @Published var selectedOrdersEntry: OrdersEntry = .storeOrders + @Published private(set) var pendingOrderScanCode: String? + @Published private var routers: [AppTab: RouterPath] = [:] /// 获取指定 Tab 对应的路由路径容器,不存在时自动创建。 func router(for tab: AppTab) -> RouterPath { diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 030f916..061483e 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -10,12 +10,12 @@ import SwiftUI /// App 根视图,负责创建全局依赖并根据登录状态切换登录页和主界面。 struct RootView: View { @Environment(\.scenePhase) private var scenePhase - @State private var appSession = AppSession() - @State private var accountContext = AccountContext() - @State private var permissionContext = PermissionContext() - @State private var scenicSpotContext = ScenicSpotContext() - @State private var appRouter = AppRouter() - @State private var toastCenter = ToastCenter() + @StateObject private var appSession = AppSession() + @StateObject private var accountContext = AccountContext() + @StateObject private var permissionContext = PermissionContext() + @StateObject private var scenicSpotContext = ScenicSpotContext() + @StateObject private var appRouter = AppRouter() + @StateObject private var toastCenter = ToastCenter() @State private var globalLoading = GlobalLoadingCenter() @State private var snapshotStore: AccountSnapshotStore @State private var apiClient: APIClient @@ -33,7 +33,7 @@ struct RootView: View { @State private var scenicSettlementAPI: ScenicSettlementAPI @State private var messageCenterAPI: MessageCenterAPI @State private var scenicQueueAPI: ScenicQueueAPI - @State private var scenicQueueRuntime = ScenicQueueRuntime() + @StateObject private var scenicQueueRuntime = ScenicQueueRuntime() @State private var liveAPI: LiveAPI @State private var operatingAreaAPI: OperatingAreaAPI @State private var pilotCertificationAPI: PilotCertificationAPI @@ -98,41 +98,41 @@ struct RootView: View { rootContent .globalToastOverlay() .globalLoadingOverlay(loadingCenter: globalLoading) - .environment(appSession) - .environment(accountContext) - .environment(permissionContext) - .environment(scenicSpotContext) - .environment(appRouter) - .environment(toastCenter) + .environmentObject(appSession) + .environmentObject(accountContext) + .environmentObject(permissionContext) + .environmentObject(scenicSpotContext) + .environmentObject(appRouter) + .environmentObject(toastCenter) .environment(\.globalLoading, globalLoading) - .environment(snapshotStore) - .environment(apiClient) - .environment(authAPI) - .environment(profileAPI) - .environment(uploadAPI) - .environment(ossUploadService) - .environment(accountContextAPI) - .environment(ordersAPI) - .environment(statisticsAPI) - .environment(paymentAPI) - .environment(walletAPI) - .environment(pushAPI) - .environment(scenicPermissionAPI) - .environment(scenicSettlementAPI) - .environment(messageCenterAPI) - .environment(scenicQueueAPI) - .environment(scenicQueueRuntime) - .environment(liveAPI) - .environment(operatingAreaAPI) - .environment(pilotCertificationAPI) - .environment(taskAPI) - .environment(projectAPI) - .environment(scheduleAPI) - .environment(inviteAPI) - .environment(assetsAPI) - .environment(punchPointAPI) - .environment(locationReportAPI) - .environment(authSessionCoordinator) + .environment(\.accountSnapshotStore, snapshotStore) + .environment(\.apiClient, apiClient) + .environment(\.authAPI, authAPI) + .environment(\.profileAPI, profileAPI) + .environment(\.uploadAPI, uploadAPI) + .environment(\.ossUploadService, ossUploadService) + .environment(\.accountContextAPI, accountContextAPI) + .environment(\.ordersAPI, ordersAPI) + .environment(\.statisticsAPI, statisticsAPI) + .environment(\.paymentAPI, paymentAPI) + .environment(\.walletAPI, walletAPI) + .environment(\.pushAPI, pushAPI) + .environment(\.scenicPermissionAPI, scenicPermissionAPI) + .environment(\.scenicSettlementAPI, scenicSettlementAPI) + .environment(\.messageCenterAPI, messageCenterAPI) + .environment(\.scenicQueueAPI, scenicQueueAPI) + .environmentObject(scenicQueueRuntime) + .environment(\.liveAPI, liveAPI) + .environment(\.operatingAreaAPI, operatingAreaAPI) + .environment(\.pilotCertificationAPI, pilotCertificationAPI) + .environment(\.taskAPI, taskAPI) + .environment(\.projectAPI, projectAPI) + .environment(\.scheduleAPI, scheduleAPI) + .environment(\.inviteAPI, inviteAPI) + .environment(\.assetsAPI, assetsAPI) + .environment(\.punchPointAPI, punchPointAPI) + .environment(\.locationReportAPI, locationReportAPI) + .environment(\.authSessionCoordinator, authSessionCoordinator) .task { apiClient.bindAuthTokenProvider { appSession.token } PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter) @@ -160,7 +160,7 @@ struct RootView: View { api: accountContextAPI ) } - .onChange(of: scenePhase) { _, newPhase in + .onChange(of: scenePhase) { newPhase in if appSession.isLoggedIn { scenicQueueRuntime.update( api: scenicQueueAPI, @@ -172,7 +172,7 @@ struct RootView: View { scenicQueueRuntime.stop() } } - .onChange(of: accountContext.currentScenic?.id) { _, _ in + .onChange(of: accountContext.currentScenic?.id) { _ in if appSession.isLoggedIn { scenicQueueRuntime.update( api: scenicQueueAPI, @@ -182,7 +182,7 @@ struct RootView: View { ) } } - .onChange(of: appSession.phase) { _, phase in + .onChange(of: appSession.phase) { phase in switch phase { case .loggedIn: PushNotificationManager.shared.requestAuthorizationAndRegister() diff --git a/suixinkan/App/State/AccountContext.swift b/suixinkan/App/State/AccountContext.swift index 961bf62..c781c19 100644 --- a/suixinkan/App/State/AccountContext.swift +++ b/suixinkan/App/State/AccountContext.swift @@ -5,7 +5,7 @@ // Created by Codex on 2026/6/18. // -import Observation +import Combine /// 当前账号的基础资料实体,用于跨页面展示昵称、手机号和头像。 struct AccountProfile: Codable, Equatable { @@ -58,14 +58,13 @@ struct BusinessScope: Codable, Equatable, Identifiable { } @MainActor -@Observable /// 账号上下文状态中心,保存当前账号资料和景区/门店作用域。 -final class AccountContext { - private(set) var profile: AccountProfile? - private(set) var scenicScopes: [BusinessScope] = [] - private(set) var storeScopes: [BusinessScope] = [] - var currentScenic: BusinessScope? - var currentStore: BusinessScope? +final class AccountContext: ObservableObject { + @Published private(set) var profile: AccountProfile? + @Published private(set) var scenicScopes: [BusinessScope] = [] + @Published private(set) var storeScopes: [BusinessScope] = [] + @Published var currentScenic: BusinessScope? + @Published var currentStore: BusinessScope? /// 应用登录成功后写入账号资料。 func applyLogin(profile: AccountProfile? = nil) { diff --git a/suixinkan/App/State/AppSession.swift b/suixinkan/App/State/AppSession.swift index 5c197df..93c53c0 100644 --- a/suixinkan/App/State/AppSession.swift +++ b/suixinkan/App/State/AppSession.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 登录阶段实体,表示根视图当前应该展示的认证状态。 enum AuthPhase: Equatable { @@ -16,11 +16,10 @@ enum AuthPhase: Equatable { } @MainActor -@Observable /// 登录会话状态中心,保存 token 和当前认证阶段。 -final class AppSession { - private(set) var phase: AuthPhase = .loggedOut - private(set) var token: String? +final class AppSession: ObservableObject { + @Published private(set) var phase: AuthPhase = .loggedOut + @Published private(set) var token: String? var isLoggedIn: Bool { phase == .loggedIn diff --git a/suixinkan/App/State/AuthSessionCoordinator.swift b/suixinkan/App/State/AuthSessionCoordinator.swift index e85afa3..ddb6b6f 100644 --- a/suixinkan/App/State/AuthSessionCoordinator.swift +++ b/suixinkan/App/State/AuthSessionCoordinator.swift @@ -6,15 +6,14 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 登录会话协调器,统一处理登录完成、退出登录和缓存同步。 final class AuthSessionCoordinator { - @ObservationIgnored private let tokenStore: SessionTokenStore - @ObservationIgnored private let snapshotStore: AccountSnapshotStore - @ObservationIgnored private let preferencesStore: AppPreferencesStore + private let tokenStore: SessionTokenStore + private let snapshotStore: AccountSnapshotStore + private let preferencesStore: AppPreferencesStore /// 初始化登录会话协调器,并注入 token、账号快照和偏好存储。 init( diff --git a/suixinkan/App/State/PermissionContext.swift b/suixinkan/App/State/PermissionContext.swift index 5992ccf..22c23fe 100644 --- a/suixinkan/App/State/PermissionContext.swift +++ b/suixinkan/App/State/PermissionContext.swift @@ -6,15 +6,14 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 权限上下文状态中心,保存角色权限、当前角色和扁平化权限 URI。 -final class PermissionContext { - private(set) var rolePermissions: [RolePermissionResponse] = [] - private(set) var permissionURIs: Set = [] - var currentRole: RoleInfo? +final class PermissionContext: ObservableObject { + @Published private(set) var rolePermissions: [RolePermissionResponse] = [] + @Published private(set) var permissionURIs: Set = [] + @Published var currentRole: RoleInfo? /// 替换角色权限列表,并尽量按缓存角色 ID 保持当前角色。 func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) { diff --git a/suixinkan/App/State/ScenicSpotContext.swift b/suixinkan/App/State/ScenicSpotContext.swift index dd61610..378a655 100644 --- a/suixinkan/App/State/ScenicSpotContext.swift +++ b/suixinkan/App/State/ScenicSpotContext.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 景点加载阶段实体,表示当前景区景点列表的加载状态。 enum ScenicSpotLoadState: Equatable { @@ -17,12 +17,11 @@ enum ScenicSpotLoadState: Equatable { } @MainActor -@Observable /// 景点上下文状态中心,保存当前景区下的景点或打卡点列表。 -final class ScenicSpotContext { - private(set) var scenicId: Int? - private(set) var spots: [ScenicSpotItem] = [] - private(set) var loadState: ScenicSpotLoadState = .idle +final class ScenicSpotContext: ObservableObject { + @Published private(set) var scenicId: Int? + @Published private(set) var spots: [ScenicSpotItem] = [] + @Published private(set) var loadState: ScenicSpotLoadState = .idle /// 按景区 ID 重新加载景点列表,失败只影响景点模块本身。 func reload(scenicId: Int?, api: AccountContextServing) async { diff --git a/suixinkan/App/State/SessionBootstrapper.swift b/suixinkan/App/State/SessionBootstrapper.swift index 35220f7..f6c7a5d 100644 --- a/suixinkan/App/State/SessionBootstrapper.swift +++ b/suixinkan/App/State/SessionBootstrapper.swift @@ -6,14 +6,13 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 登录态启动恢复器,负责冷启动时从缓存恢复并校验 token。 final class SessionBootstrapper { - @ObservationIgnored private let tokenStore: SessionTokenStore - @ObservationIgnored private let snapshotStore: AccountSnapshotStore + private let tokenStore: SessionTokenStore + private let snapshotStore: AccountSnapshotStore private var didAttemptRestore = false /// 初始化启动恢复器,并注入 token 与账号快照存储。 diff --git a/suixinkan/App/State/ToastCenter.swift b/suixinkan/App/State/ToastCenter.swift index 7138b24..db72217 100644 --- a/suixinkan/App/State/ToastCenter.swift +++ b/suixinkan/App/State/ToastCenter.swift @@ -5,17 +5,16 @@ // Created by Codex on 2026/6/18. // -import Observation +import Combine import SwiftUI @MainActor -@Observable /// 全局 Toast 状态中心,负责保存和清除当前提示文案。 -final class ToastCenter { - fileprivate var message: String? - @ObservationIgnored private let autoDismissNanoseconds: UInt64 - @ObservationIgnored private var dismissTask: Task? - @ObservationIgnored private var displayToken: UInt64 = 0 +final class ToastCenter: ObservableObject { + @Published fileprivate var message: String? + private let autoDismissNanoseconds: UInt64 + @Published private var dismissTask: Task? + @Published private var displayToken: UInt64 = 0 /// 创建 Toast 状态中心,默认 2.2 秒后自动隐藏。 init(autoDismissNanoseconds: UInt64 = 2_200_000_000) { @@ -65,7 +64,7 @@ final class ToastCenter { /// 全局 Toast 叠加层修饰器,负责把 Toast 展示到页面顶部。 private struct GlobalToastOverlay: ViewModifier { - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var toastCenter: ToastCenter func body(content: Content) -> some View { content diff --git a/suixinkan/App/suixinkanApp.swift b/suixinkan/App/suixinkanApp.swift index 22dccaf..cc91b63 100644 --- a/suixinkan/App/suixinkanApp.swift +++ b/suixinkan/App/suixinkanApp.swift @@ -12,6 +12,10 @@ import SwiftUI struct suixinkanApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + init() { + AppUITestLaunchState.resetIfNeeded() + } + var body: some Scene { WindowGroup { RootView() diff --git a/suixinkan/Core/Design/AppContentUnavailableView.swift b/suixinkan/Core/Design/AppContentUnavailableView.swift new file mode 100644 index 0000000..b1ccaa6 --- /dev/null +++ b/suixinkan/Core/Design/AppContentUnavailableView.swift @@ -0,0 +1,75 @@ +// +// AppContentUnavailableView.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import SwiftUI + +/// iOS 16 兼容的空状态视图;iOS 17+ 自动使用系统 AppContentUnavailableView。 +struct AppContentUnavailableView: View { + private let label: LabelContent + private let description: DescriptionContent + private let actions: ActionsContent + + init( + @ViewBuilder label: () -> LabelContent, + @ViewBuilder description: () -> DescriptionContent, + @ViewBuilder actions: () -> ActionsContent + ) { + self.label = label() + self.description = description() + self.actions = actions() + } + + var body: some View { + if #available(iOS 17.0, *) { + AppContentUnavailableView { + label + } description: { + description + } actions: { + actions + } + } else { + VStack(spacing: AppMetrics.Spacing.small) { + label + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + description + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .multilineTextAlignment(.center) + actions + .padding(.top, AppMetrics.Spacing.xSmall) + } + .frame(maxWidth: .infinity) + .padding(AppMetrics.Spacing.large) + } + } +} + +extension AppContentUnavailableView where LabelContent == Label, DescriptionContent == EmptyView, ActionsContent == EmptyView { + init(_ title: String, systemImage: String) { + self.init { + Label(title, systemImage: systemImage) + } description: { + EmptyView() + } actions: { + EmptyView() + } + } +} + +extension AppContentUnavailableView where LabelContent == Label, DescriptionContent == Text, ActionsContent == EmptyView { + init(_ title: String, systemImage: String, description: Text) { + self.init { + Label(title, systemImage: systemImage) + } description: { + description + } actions: { + EmptyView() + } + } +} diff --git a/suixinkan/Core/Design/GlobalLoadingCenter.swift b/suixinkan/Core/Design/GlobalLoadingCenter.swift index 06eda1d..f25b962 100644 --- a/suixinkan/Core/Design/GlobalLoadingCenter.swift +++ b/suixinkan/Core/Design/GlobalLoadingCenter.swift @@ -77,7 +77,7 @@ final class GlobalLoadingCenter { fileprivate final class GlobalLoadingState: ObservableObject { @Published fileprivate var isVisible = false @Published fileprivate var message = "" - fileprivate var activeCount = 0 + @Published fileprivate var activeCount = 0 } /// Lottie Loading 动画容器,负责播放主包中的 loading.json。 diff --git a/suixinkan/Core/Location/ForegroundLocationProvider.swift b/suixinkan/Core/Location/ForegroundLocationProvider.swift index ac8d559..b5594c9 100644 --- a/suixinkan/Core/Location/ForegroundLocationProvider.swift +++ b/suixinkan/Core/Location/ForegroundLocationProvider.swift @@ -7,7 +7,7 @@ import CoreLocation import Foundation -import Observation +import Combine /// 前台定位结果实体,表示一次即时定位得到的坐标和可展示地址。 struct ForegroundLocationResult: Equatable { @@ -18,11 +18,10 @@ struct ForegroundLocationResult: Equatable { /// 前台定位 Provider,只负责当前页面主动请求位置,不缓存、不后台持续定位。 @MainActor -@Observable -final class ForegroundLocationProvider: NSObject { - @ObservationIgnored private let manager = CLLocationManager() - @ObservationIgnored private let geocoder = CLGeocoder() - @ObservationIgnored private var continuation: CheckedContinuation? +final class ForegroundLocationProvider: NSObject, ObservableObject { + private let manager = CLLocationManager() + private let geocoder = CLGeocoder() + @Published private var continuation: CheckedContinuation? override init() { super.init() diff --git a/suixinkan/Core/Networking/APIClient.swift b/suixinkan/Core/Networking/APIClient.swift index e3da623..1f729b5 100644 --- a/suixinkan/Core/Networking/APIClient.swift +++ b/suixinkan/Core/Networking/APIClient.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// URLSession 抽象协议,用于让网络客户端支持测试替身。 protocol URLSessionProtocol { @@ -17,13 +17,12 @@ protocol URLSessionProtocol { extension URLSession: URLSessionProtocol {} @MainActor -@Observable /// 统一网络请求客户端,负责构造请求、注入 token、校验响应和解析业务 Envelope。 final class APIClient { - @ObservationIgnored private let session: URLSessionProtocol - @ObservationIgnored private let encoder: JSONEncoder - @ObservationIgnored private let decoder: JSONDecoder - @ObservationIgnored private var authTokenProvider: (() -> String?)? + private let session: URLSessionProtocol + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private var authTokenProvider: (() -> String?)? private let environment: APIEnvironment private let appVersion: String diff --git a/suixinkan/Core/Push/PushAPI.swift b/suixinkan/Core/Push/PushAPI.swift index c8dcc69..d48295f 100644 --- a/suixinkan/Core/Push/PushAPI.swift +++ b/suixinkan/Core/Push/PushAPI.swift @@ -6,13 +6,12 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 推送 API,负责把 iOS APNs token 上报到当前后端兼容接口。 final class PushAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化推送 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Core/Queue/ScenicQueueRuntime.swift b/suixinkan/Core/Queue/ScenicQueueRuntime.swift index 5da3810..3d4a764 100644 --- a/suixinkan/Core/Queue/ScenicQueueRuntime.swift +++ b/suixinkan/Core/Queue/ScenicQueueRuntime.swift @@ -6,31 +6,30 @@ // import AVFoundation -import Observation +import Combine import SwiftUI import UIKit @MainActor -@Observable /// 排队运行时,页面外按开关短轮询当前打卡点并语音播报队列变化。 -final class ScenicQueueRuntime { - private(set) var isMonitoring = false - private(set) var lastPollText = "--" - private(set) var lastSpokenText = "" - private(set) var lastQueueCount = 0 - private(set) var lastError: String? +final class ScenicQueueRuntime: ObservableObject { + @Published private(set) var isMonitoring = false + @Published private(set) var lastPollText = "--" + @Published private(set) var lastSpokenText = "" + @Published private(set) var lastQueueCount = 0 + @Published private(set) var lastError: String? - @ObservationIgnored private let speaker = ScenicQueueSpeechService() - @ObservationIgnored private weak var api: (any ScenicQueueServing)? - @ObservationIgnored private var userId: String? - @ObservationIgnored private var scenicId: Int? - @ObservationIgnored private var scenePhase: ScenePhase = .active - @ObservationIgnored private var pollTask: Task? - @ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid - @ObservationIgnored private var announcementState = ScenicQueueAnnouncementState() - @ObservationIgnored private var lastScenicId: Int? - @ObservationIgnored private var lastSpotId: Int? - @ObservationIgnored private var suspendedByQueueScreen = false + private let speaker = ScenicQueueSpeechService() + private weak var api: (any ScenicQueueServing)? + @Published private var userId: String? + @Published private var scenicId: Int? + @Published private var scenePhase: ScenePhase = .active + @Published private var pollTask: Task? + @Published private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid + @Published private var announcementState = ScenicQueueAnnouncementState() + @Published private var lastScenicId: Int? + @Published private var lastSpotId: Int? + @Published private var suspendedByQueueScreen = false /// 语音播报是否开启。 var voiceEnabled: Bool { diff --git a/suixinkan/Core/Storage/AccountSnapshotStore.swift b/suixinkan/Core/Storage/AccountSnapshotStore.swift index 9f0fc49..125bbe5 100644 --- a/suixinkan/Core/Storage/AccountSnapshotStore.swift +++ b/suixinkan/Core/Storage/AccountSnapshotStore.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 账号缓存快照实体,保存可重建、非敏感的账号展示和业务上下文。 struct AccountSnapshot: Codable, Equatable { @@ -41,13 +41,12 @@ struct AccountSnapshot: Codable, Equatable { } } -@Observable /// 账号快照存储服务,使用 UserDefaults 保存非敏感登录上下文。 final class AccountSnapshotStore { - @ObservationIgnored private let defaults: UserDefaults - @ObservationIgnored private let key: String - @ObservationIgnored private let encoder: JSONEncoder - @ObservationIgnored private let decoder: JSONDecoder + private let defaults: UserDefaults + private let key: String + private let encoder: JSONEncoder + private let decoder: JSONDecoder /// 初始化账号快照存储服务,并允许测试注入独立的 UserDefaults。 init( diff --git a/suixinkan/Core/Storage/AppPreferencesStore.swift b/suixinkan/Core/Storage/AppPreferencesStore.swift index d89cd5c..55b2f8c 100644 --- a/suixinkan/Core/Storage/AppPreferencesStore.swift +++ b/suixinkan/Core/Storage/AppPreferencesStore.swift @@ -6,14 +6,13 @@ // import Foundation -import Observation +import Combine -@Observable /// App 偏好存储服务,保存上次手机号和协议状态等非敏感设置。 final class AppPreferencesStore { - @ObservationIgnored private let defaults: UserDefaults - @ObservationIgnored private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username" - @ObservationIgnored private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted" + private let defaults: UserDefaults + private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username" + private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted" /// 初始化偏好存储服务,并允许测试注入独立的 UserDefaults。 init(defaults: UserDefaults = .standard) { @@ -43,4 +42,10 @@ final class AppPreferencesStore { func loadPrivacyAgreementAccepted() -> Bool { defaults.bool(forKey: privacyAgreementAcceptedKey) } + + /// 清空登录页偏好。 + func clear() { + defaults.removeObject(forKey: lastLoginUsernameKey) + defaults.removeObject(forKey: privacyAgreementAcceptedKey) + } } diff --git a/suixinkan/Core/Storage/SessionTokenStore.swift b/suixinkan/Core/Storage/SessionTokenStore.swift index fcc505a..a24c823 100644 --- a/suixinkan/Core/Storage/SessionTokenStore.swift +++ b/suixinkan/Core/Storage/SessionTokenStore.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine import Security /// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。 @@ -21,11 +21,10 @@ enum SessionTokenStoreError: LocalizedError { } } -@Observable /// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。 final class SessionTokenStore { - @ObservationIgnored private let service: String - @ObservationIgnored private let account: String + private let service: String + private let account: String /// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。 init( diff --git a/suixinkan/Core/UI/RemoteImage.swift b/suixinkan/Core/UI/RemoteImage.swift index f430f83..93fdb0f 100644 --- a/suixinkan/Core/UI/RemoteImage.swift +++ b/suixinkan/Core/UI/RemoteImage.swift @@ -40,7 +40,7 @@ struct RemoteImage: View { placeholder() } } - .onChange(of: normalizedURLString) { _, _ in + .onChange(of: normalizedURLString) { _ in didFail = false } } diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index ab31e30..736f97a 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine import UniformTypeIdentifiers #if canImport(AlibabaCloudOSS) @@ -51,10 +51,9 @@ protocol OSSUploadServing { } @MainActor -@Observable /// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。 final class OSSUploadService { - @ObservationIgnored private let configService: any OSSConfigServing + private let configService: any OSSConfigServing /// 初始化 OSS 上传服务,并注入 STS 配置服务。 init(configService: any OSSConfigServing) { diff --git a/suixinkan/Core/Upload/UploadAPI.swift b/suixinkan/Core/Upload/UploadAPI.swift index 1209938..ceb7ce4 100644 --- a/suixinkan/Core/Upload/UploadAPI.swift +++ b/suixinkan/Core/Upload/UploadAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// OSS 配置服务协议,抽象 STS token 获取能力,便于上传服务测试替换。 @MainActor @@ -16,10 +16,9 @@ protocol OSSConfigServing { } @MainActor -@Observable /// 上传 API,封装文件上传前需要的服务端配置接口。 final class UploadAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化上传 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Account/API/AccountContextAPI.swift b/suixinkan/Features/Account/API/AccountContextAPI.swift index 820680f..08f184a 100644 --- a/suixinkan/Features/Account/API/AccountContextAPI.swift +++ b/suixinkan/Features/Account/API/AccountContextAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine @MainActor /// 账号上下文服务协议,抽象权限、景区、门店和景点读取能力以便测试替换。 @@ -25,10 +25,9 @@ protocol AccountContextServing { } @MainActor -@Observable /// 账号上下文 API,封装角色权限、景区、门店和景点读取接口。 final class AccountContextAPI: AccountContextServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化账号上下文 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Assets/API/AssetsAPI.swift b/suixinkan/Features/Assets/API/AssetsAPI.swift index f5d4110..63a3348 100644 --- a/suixinkan/Features/Assets/API/AssetsAPI.swift +++ b/suixinkan/Features/Assets/API/AssetsAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 资产服务协议,抽象云盘、媒体库和相册接口以便 ViewModel 测试替换。 @MainActor @@ -83,9 +83,8 @@ protocol AssetsServing { /// 资产 API,负责封装相册云盘和素材管理相关接口。 @MainActor -@Observable final class AssetsAPI: AssetsServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化资产 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift b/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift index ef3f0fb..b2e1100 100644 --- a/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift +++ b/suixinkan/Features/Assets/ViewModels/AssetsViewModels.swift @@ -6,15 +6,14 @@ // import Foundation -import Observation +import Combine /// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。 @MainActor -@Observable -final class CloudTransferStore { +final class CloudTransferStore: ObservableObject { static let shared = CloudTransferStore() - private(set) var records: [CloudTransferItem] = [] + @Published private(set) var records: [CloudTransferItem] = [] /// 新增一条传输记录并返回记录 ID。 @discardableResult @@ -56,19 +55,18 @@ final class CloudTransferStore { /// 云盘 ViewModel,负责文件列表、筛选、分页、文件操作和上传闭环。 @MainActor -@Observable -final class CloudStorageViewModel { - var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] - var files: [CloudDriveFile] = [] - var total = 0 - var page = 1 - var searchText = "" - var selectedFilter: CloudDriveFilter = .all - var selectedSort: CloudDriveSort = .updatedDesc - var isLoading = false - var isLoadingMore = false - var isMutating = false - var errorMessage: String? +final class CloudStorageViewModel: ObservableObject { + @Published var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] + @Published var files: [CloudDriveFile] = [] + @Published var total = 0 + @Published var page = 1 + @Published var searchText = "" + @Published var selectedFilter: CloudDriveFilter = .all + @Published var selectedSort: CloudDriveSort = .updatedDesc + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var isMutating = false + @Published var errorMessage: String? private let pageSize = 20 @@ -260,20 +258,19 @@ final class CloudStorageViewModel { /// 媒体库 ViewModel,负责素材或样片列表、筛选、分页、详情和上下架。 @MainActor -@Observable -final class MediaLibraryViewModel { +final class MediaLibraryViewModel: ObservableObject { let kind: MediaLibraryKind - var items: [MediaLibraryItem] = [] - var selectedDetail: MediaLibraryDetail? - var orderInfo: MediaLibraryOrderInfo? - var total = 0 - var page = 1 - var keyword = "" - var selectedAuditFilter: MediaLibraryAuditFilter = .all - var isLoading = false - var isLoadingMore = false - var isLoadingDetail = false - var errorMessage: String? + @Published var items: [MediaLibraryItem] = [] + @Published var selectedDetail: MediaLibraryDetail? + @Published var orderInfo: MediaLibraryOrderInfo? + @Published var total = 0 + @Published var page = 1 + @Published var keyword = "" + @Published var selectedAuditFilter: MediaLibraryAuditFilter = .all + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var isLoadingDetail = false + @Published var errorMessage: String? private let pageSize = 10 @@ -396,20 +393,19 @@ final class MediaLibraryViewModel { /// 媒体库上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材或样片。 @MainActor -@Observable -final class MediaLibraryEditorViewModel { - var name = "" - var description = "" - var selectedSpotId: Int? - var selectedProjectId: Int? - var projects: [PhotographerProjectItem] = [] - var tagsText = "" - var coverFile: MediaLocalUploadFile? - var mediaFiles: [MediaLocalUploadFile] = [] - var isSubmitting = false - var isLoadingProjects = false - var errorMessage: String? - var didSubmitSuccessfully = false +final class MediaLibraryEditorViewModel: ObservableObject { + @Published var name = "" + @Published var description = "" + @Published var selectedSpotId: Int? + @Published var selectedProjectId: Int? + @Published var projects: [PhotographerProjectItem] = [] + @Published var tagsText = "" + @Published var coverFile: MediaLocalUploadFile? + @Published var mediaFiles: [MediaLocalUploadFile] = [] + @Published var isSubmitting = false + @Published var isLoadingProjects = false + @Published var errorMessage: String? + @Published var didSubmitSuccessfully = false private let editingId: Int? @@ -611,18 +607,17 @@ final class MediaLibraryEditorViewModel { /// 相册列表 ViewModel,负责相册文件夹搜索、日期筛选、分页和新建相册。 @MainActor -@Observable -final class AlbumListViewModel { - var folders: [AlbumFolderItem] = [] - var total = 0 - var page = 1 - var searchText = "" - var startTime = "" - var endTime = "" - var isLoading = false - var isLoadingMore = false - var isMutating = false - var errorMessage: String? +final class AlbumListViewModel: ObservableObject { + @Published var folders: [AlbumFolderItem] = [] + @Published var total = 0 + @Published var page = 1 + @Published var searchText = "" + @Published var startTime = "" + @Published var endTime = "" + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var isMutating = false + @Published var errorMessage: String? private let pageSize = 10 @@ -716,18 +711,17 @@ final class AlbumListViewModel { /// 相册详情 ViewModel,负责相册信息、图片/视频列表和文件操作。 @MainActor -@Observable -final class AlbumDetailViewModel { +final class AlbumDetailViewModel: ObservableObject { let folderId: Int - var folder: AlbumFolderItem? - var files: [AlbumFileItem] = [] - var selectedTab: AlbumFileTab = .image - var total = 0 - var page = 1 - var isLoading = false - var isLoadingMore = false - var isMutating = false - var errorMessage: String? + @Published var folder: AlbumFolderItem? + @Published var files: [AlbumFileItem] = [] + @Published var selectedTab: AlbumFileTab = .image + @Published var total = 0 + @Published var page = 1 + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var isMutating = false + @Published var errorMessage: String? private let pageSize = 20 @@ -863,16 +857,15 @@ final class AlbumDetailViewModel { /// 相册预览上传 ViewModel,负责选择相册、本地文件上传和相册入库。 @MainActor -@Observable -final class AlbumTrailerViewModel { - var folders: [AlbumFolderItem] = [] - var selectedFolderId: Int? - var localFiles: [AlbumLocalUploadFile] = [] - var uploadProgress = 0 - var isLoadingFolders = false - var isSubmitting = false - var errorMessage: String? - var didUploadSuccessfully = false +final class AlbumTrailerViewModel: ObservableObject { + @Published var folders: [AlbumFolderItem] = [] + @Published var selectedFolderId: Int? + @Published var localFiles: [AlbumLocalUploadFile] = [] + @Published var uploadProgress = 0 + @Published var isLoadingFolders = false + @Published var isSubmitting = false + @Published var errorMessage: String? + @Published var didUploadSuccessfully = false /// 加载可上传的相册列表。 func loadFolders(api: any AssetsServing, scenicId: Int?) async { diff --git a/suixinkan/Features/Assets/Views/AlbumViews.swift b/suixinkan/Features/Assets/Views/AlbumViews.swift index 659bb57..8aaa646 100644 --- a/suixinkan/Features/Assets/Views/AlbumViews.swift +++ b/suixinkan/Features/Assets/Views/AlbumViews.swift @@ -12,12 +12,12 @@ import UniformTypeIdentifiers /// 相册管理页面,展示相册列表、筛选和新建相册入口。 struct AlbumListView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(AssetsAPI.self) private var assetsAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.assetsAPI) private var assetsAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = AlbumListViewModel() + @StateObject private var viewModel = AlbumListViewModel() @State private var showCreateSheet = false @State private var newAlbumName = "" @State private var newAlbumRemark = "" @@ -158,12 +158,12 @@ struct AlbumListView: View { /// 相册详情页面,展示图片/视频列表并支持封面、删除和编辑。 struct AlbumDetailView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(AssetsAPI.self) private var assetsAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.assetsAPI) private var assetsAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel: AlbumDetailViewModel + @StateObject private var viewModel: AlbumDetailViewModel @State private var actionFile: AlbumFileItem? @State private var previewFile: AlbumFileItem? @State private var showRenameSheet = false @@ -174,7 +174,7 @@ struct AlbumDetailView: View { /// 初始化相册详情页面。 init(folderId: Int, summary: AlbumFolderItem? = nil) { - _viewModel = State(initialValue: AlbumDetailViewModel(folderId: folderId, summary: summary)) + _viewModel = StateObject(wrappedValue: AlbumDetailViewModel(folderId: folderId, summary: summary)) } var body: some View { @@ -282,7 +282,7 @@ struct AlbumDetailView: View { } } .pickerStyle(.segmented) - .onChange(of: viewModel.selectedTab) { _, tab in + .onChange(of: viewModel.selectedTab) { tab in Task { await viewModel.selectTab(tab, api: assetsAPI, scenicId: accountContext.currentScenic?.id) } } } @@ -378,14 +378,14 @@ struct AlbumTrailerEntryView: View { /// 相册预览上传页面,负责选择相册、本地文件和提交上传。 struct AlbumTrailerUploadView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(AssetsAPI.self) private var assetsAPI - @Environment(OSSUploadService.self) private var uploadService - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.assetsAPI) private var assetsAPI + @Environment(\.ossUploadService) private var uploadService + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = AlbumTrailerViewModel() + @StateObject private var viewModel = AlbumTrailerViewModel() @State private var selectedItems: [PhotosPickerItem] = [] let initialFolderId: Int? @@ -412,7 +412,7 @@ struct AlbumTrailerUploadView: View { toastCenter.show(message) } } - .onChange(of: selectedItems) { _, items in + .onChange(of: selectedItems) { items in Task { await loadPickedFiles(items) } } } diff --git a/suixinkan/Features/Assets/Views/CloudStorageViews.swift b/suixinkan/Features/Assets/Views/CloudStorageViews.swift index a3e9910..77eb2c7 100644 --- a/suixinkan/Features/Assets/Views/CloudStorageViews.swift +++ b/suixinkan/Features/Assets/Views/CloudStorageViews.swift @@ -14,14 +14,14 @@ import UIKit /// 相册云盘页面,展示文件夹浏览、筛选、上传、预览和文件操作。 struct CloudStorageView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(AssetsAPI.self) private var assetsAPI - @Environment(OSSUploadService.self) private var uploadService - @Environment(RouterPath.self) private var router - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.assetsAPI) private var assetsAPI + @Environment(\.ossUploadService) private var uploadService + @EnvironmentObject private var router: RouterPath + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = CloudStorageViewModel() + @StateObject private var viewModel = CloudStorageViewModel() @State private var transferStore = CloudTransferStore.shared @State private var selectedItems: [PhotosPickerItem] = [] @State private var actionItem: CloudDriveFile? @@ -70,7 +70,7 @@ struct CloudStorageView: View { guard viewModel.files.isEmpty else { return } await reload(showLoading: true) } - .onChange(of: selectedItems) { _, items in + .onChange(of: selectedItems) { items in Task { await upload(items) } } .confirmationDialog("文件操作", isPresented: actionDialogBinding, presenting: actionItem) { item in @@ -176,7 +176,7 @@ struct CloudStorageView: View { } } .pickerStyle(.segmented) - .onChange(of: viewModel.selectedFilter) { _, _ in + .onChange(of: viewModel.selectedFilter) { _ in Task { await reload(showLoading: true) } } @@ -186,7 +186,7 @@ struct CloudStorageView: View { } } .pickerStyle(.segmented) - .onChange(of: viewModel.selectedSort) { _, _ in + .onChange(of: viewModel.selectedSort) { _ in Task { await reload(showLoading: true) } } } diff --git a/suixinkan/Features/Assets/Views/MediaLibraryViews.swift b/suixinkan/Features/Assets/Views/MediaLibraryViews.swift index e5e69db..4b047ca 100644 --- a/suixinkan/Features/Assets/Views/MediaLibraryViews.swift +++ b/suixinkan/Features/Assets/Views/MediaLibraryViews.swift @@ -12,18 +12,18 @@ import SwiftUI struct MediaLibraryView: View { let kind: MediaLibraryKind - @Environment(AssetsAPI.self) private var assetsAPI - @Environment(RouterPath.self) private var router - @Environment(ToastCenter.self) private var toastCenter + @Environment(\.assetsAPI) private var assetsAPI + @EnvironmentObject private var router: RouterPath + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel: MediaLibraryViewModel + @StateObject private var viewModel: MediaLibraryViewModel @State private var selectedItem: MediaLibraryItem? /// 初始化媒体库页面。 init(kind: MediaLibraryKind = .material) { self.kind = kind - _viewModel = State(initialValue: MediaLibraryViewModel(kind: kind)) + _viewModel = StateObject(wrappedValue: MediaLibraryViewModel(kind: kind)) } var body: some View { @@ -54,7 +54,7 @@ struct MediaLibraryView: View { guard viewModel.items.isEmpty else { return } await reload(showLoading: true) } - .navigationDestination(item: $selectedItem) { item in + .appNavigationDestination(item: $selectedItem) { item in MediaLibraryDetailView(item: item, listViewModel: viewModel) } } @@ -82,7 +82,7 @@ struct MediaLibraryView: View { } } .pickerStyle(.segmented) - .onChange(of: viewModel.selectedAuditFilter) { _, _ in + .onChange(of: viewModel.selectedAuditFilter) { _ in Task { await reload(showLoading: true) } } } @@ -235,8 +235,8 @@ struct MediaLibraryDetailView: View { let item: MediaLibraryItem let listViewModel: MediaLibraryViewModel - @Environment(AssetsAPI.self) private var assetsAPI - @Environment(ToastCenter.self) private var toastCenter + @Environment(\.assetsAPI) private var assetsAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading @@ -415,15 +415,15 @@ struct MediaLibraryUploadView: View { let kind: MediaLibraryKind let editingDetail: MediaLibraryDetail? - @Environment(AccountContext.self) private var accountContext - @Environment(ScenicSpotContext.self) private var scenicSpotContext - @Environment(AssetsAPI.self) private var assetsAPI - @Environment(OSSUploadService.self) private var uploadService - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var scenicSpotContext: ScenicSpotContext + @Environment(\.assetsAPI) private var assetsAPI + @Environment(\.ossUploadService) private var uploadService + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading - @State private var viewModel: MediaLibraryEditorViewModel + @StateObject private var viewModel: MediaLibraryEditorViewModel @State private var coverSelection: PhotosPickerItem? @State private var mediaSelection: [PhotosPickerItem] = [] @@ -431,7 +431,7 @@ struct MediaLibraryUploadView: View { init(kind: MediaLibraryKind = .material, editingDetail: MediaLibraryDetail? = nil) { self.kind = kind self.editingDetail = editingDetail - _viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail)) + _viewModel = StateObject(wrappedValue: MediaLibraryEditorViewModel(detail: editingDetail)) } var body: some View { @@ -457,10 +457,10 @@ struct MediaLibraryUploadView: View { toastCenter.show(message) } } - .onChange(of: coverSelection) { _, item in + .onChange(of: coverSelection) { item in Task { await loadCover(item) } } - .onChange(of: mediaSelection) { _, items in + .onChange(of: mediaSelection) { items in Task { await loadMedia(items) } } } diff --git a/suixinkan/Features/Auth/API/AuthAPI.swift b/suixinkan/Features/Auth/API/AuthAPI.swift index 846ab1c..37b1907 100644 --- a/suixinkan/Features/Auth/API/AuthAPI.swift +++ b/suixinkan/Features/Auth/API/AuthAPI.swift @@ -6,13 +6,12 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 登录认证 API,封装 v9 登录和账号选择相关接口。 final class AuthAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化登录 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift index 74d591f..36532a4 100644 --- a/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift +++ b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 登录输入焦点实体,标识当前需要聚焦的输入框。 enum LoginField: Hashable { @@ -68,17 +68,16 @@ enum LoginFlowError: LocalizedError { } @MainActor -@Observable /// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。 -final class LoginViewModel { - var username = "18651857230" - var password = "zhifly666" - var privacyChecked = false - var isLoading = false - var isSelectingAccount = false - var showsPassword = false - var showsAgreementSheet = false - var pendingAccountSelection: AccountSelectionPayload? +final class LoginViewModel: ObservableObject { + @Published var username = "18651857230" + @Published var password = "zhifly666" + @Published var privacyChecked = false + @Published var isLoading = false + @Published var isSelectingAccount = false + @Published var showsPassword = false + @Published var showsAgreementSheet = false + @Published var pendingAccountSelection: AccountSelectionPayload? var canSubmit: Bool { isValidPhone && !trimmedPassword.isEmpty diff --git a/suixinkan/Features/Auth/Views/AccountSelectionView.swift b/suixinkan/Features/Auth/Views/AccountSelectionView.swift index 786dfe3..8f518e2 100644 --- a/suixinkan/Features/Auth/Views/AccountSelectionView.swift +++ b/suixinkan/Features/Auth/Views/AccountSelectionView.swift @@ -44,7 +44,7 @@ struct AccountSelectionView: View { @ViewBuilder private var accountList: some View { if payload.accounts.isEmpty { - ContentUnavailableView( + AppContentUnavailableView( "暂无可用账号", systemImage: "person.crop.circle.badge.exclamationmark", description: Text("当前账号没有可用的景区账号或门店账号,请联系管理员。") diff --git a/suixinkan/Features/Auth/Views/LoginView.swift b/suixinkan/Features/Auth/Views/LoginView.swift index 54d8fe9..d0647e1 100644 --- a/suixinkan/Features/Auth/Views/LoginView.swift +++ b/suixinkan/Features/Auth/Views/LoginView.swift @@ -9,17 +9,17 @@ import SwiftUI /// 登录页视图,负责展示登录表单、协议确认和多账号选择入口。 struct LoginView: View { - @Environment(AppSession.self) private var appSession - @Environment(AccountContext.self) private var accountContext - @Environment(PermissionContext.self) private var permissionContext - @Environment(ToastCenter.self) private var toastCenter - @Environment(AuthAPI.self) private var authAPI - @Environment(ProfileAPI.self) private var profileAPI - @Environment(AccountContextAPI.self) private var accountContextAPI - @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator + @EnvironmentObject private var appSession: AppSession + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var permissionContext: PermissionContext + @EnvironmentObject private var toastCenter: ToastCenter + @Environment(\.authAPI) private var authAPI + @Environment(\.profileAPI) private var profileAPI + @Environment(\.accountContextAPI) private var accountContextAPI + @Environment(\.authSessionCoordinator) private var authSessionCoordinator @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var viewModel = LoginViewModel() + @StateObject private var viewModel = LoginViewModel() @FocusState private var focusedField: LoginField? private var contentMaxWidth: CGFloat { @@ -76,11 +76,11 @@ struct LoginView: View { ) } } - .onChange(of: viewModel.username) { _, _ in + .onChange(of: viewModel.username) { _ in viewModel.normalizeUsernameCountryCodeIfNeeded() dismissLoginToastIfNeeded() } - .onChange(of: viewModel.password) { _, _ in + .onChange(of: viewModel.password) { _ in dismissLoginToastIfNeeded() } .task { @@ -462,12 +462,12 @@ private struct LoginAgreementConsentSheet: View { #Preview { LoginView() - .environment(AppSession()) - .environment(AccountContext()) - .environment(PermissionContext()) - .environment(ToastCenter()) - .environment(AuthAPI(client: APIClient())) - .environment(ProfileAPI(client: APIClient())) - .environment(AccountContextAPI(client: APIClient())) - .environment(AuthSessionCoordinator()) + .environmentObject(AppSession()) + .environmentObject(AccountContext()) + .environmentObject(PermissionContext()) + .environmentObject(ToastCenter()) + .environment(\.authAPI, AuthAPI(client: APIClient())) + .environment(\.profileAPI, ProfileAPI(client: APIClient())) + .environment(\.accountContextAPI, AccountContextAPI(client: APIClient())) + .environment(\.authSessionCoordinator, AuthSessionCoordinator()) } diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index ab19f16..48eb747 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -6,13 +6,12 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 首页 ViewModel,根据当前角色权限构建首页菜单列表。 -final class HomeViewModel { - private(set) var menuItems: [HomeMenuItem] = [] +final class HomeViewModel: ObservableObject { + @Published private(set) var menuItems: [HomeMenuItem] = [] /// 菜单排序权重,与旧工程和 Android 端保持一致。 private let preferredOrder: [String] = [ diff --git a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift index 4660562..23d1fa6 100644 --- a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift +++ b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift @@ -9,12 +9,12 @@ import SwiftUI /// 首页全部功能视图,展示常用应用和当前角色拥有的更多功能。 struct HomeMoreFunctionsView: View { - @Environment(PermissionContext.self) private var permissionContext - @Environment(AppRouter.self) private var appRouter - @Environment(RouterPath.self) private var router + @EnvironmentObject private var permissionContext: PermissionContext + @EnvironmentObject private var appRouter: AppRouter + @EnvironmentObject private var router: RouterPath @Environment(\.dismiss) private var dismiss - @State private var viewModel = HomeViewModel() + @StateObject private var viewModel = HomeViewModel() @State private var commonUris: [String] = [] private let commonMenuStore = HomeCommonMenuStore() @@ -49,10 +49,10 @@ struct HomeMoreFunctionsView: View { .task { rebuildMenus() } - .onChange(of: permissionContext.currentRole?.id) { _, _ in + .onChange(of: permissionContext.currentRole?.id) { _ in rebuildMenus() } - .onChange(of: permissionContext.rolePermissions.count) { _, _ in + .onChange(of: permissionContext.rolePermissions.count) { _ in rebuildMenus() } } diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift index a17d25b..5ba0d10 100644 --- a/suixinkan/Features/Home/Views/HomeView.swift +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -10,13 +10,13 @@ import SwiftUI /// 首页主视图,展示景区、工作状态、快捷操作和权限菜单入口。 struct HomeView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(PermissionContext.self) private var permissionContext - @Environment(AppRouter.self) private var appRouter - @Environment(RouterPath.self) private var router + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var permissionContext: PermissionContext + @EnvironmentObject private var appRouter: AppRouter + @EnvironmentObject private var router: RouterPath @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var viewModel = HomeViewModel() + @StateObject private var viewModel = HomeViewModel() @State private var commonUris: [String] = [] @State private var isOnline = false @State private var reminderMinutes = 0 @@ -113,10 +113,10 @@ struct HomeView: View { guard isOnline, secondsUntilReport > 0 else { return } secondsUntilReport -= 1 } - .onChange(of: permissionContext.currentRole?.id) { _, _ in + .onChange(of: permissionContext.currentRole?.id) { _ in rebuildMenusFromCurrentContext() } - .onChange(of: permissionContext.rolePermissions.count) { _, _ in + .onChange(of: permissionContext.rolePermissions.count) { _ in rebuildMenusFromCurrentContext() } .alert("切换在线状态", isPresented: $showOnlineDialog) { @@ -423,9 +423,9 @@ struct HomeView: View { #Preview { NavigationStack { HomeView() - .environment(AccountContext()) - .environment(PermissionContext()) - .environment(AppRouter()) - .environment(RouterPath()) + .environmentObject(AccountContext()) + .environmentObject(PermissionContext()) + .environmentObject(AppRouter()) + .environmentObject(RouterPath()) } } diff --git a/suixinkan/Features/Invite/API/InviteAPI.swift b/suixinkan/Features/Invite/API/InviteAPI.swift index fb4f9fa..6eda8a0 100644 --- a/suixinkan/Features/Invite/API/InviteAPI.swift +++ b/suixinkan/Features/Invite/API/InviteAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 邀请服务协议,抽象邀请信息和邀请记录接口。 @MainActor @@ -20,9 +20,8 @@ protocol InviteServing { /// 邀请 API,封装摄影师邀请相关接口。 @MainActor -@Observable final class InviteAPI: InviteServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化邀请 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Invite/ViewModels/InviteViewModels.swift b/suixinkan/Features/Invite/ViewModels/InviteViewModels.swift index f7631a4..038b9ad 100644 --- a/suixinkan/Features/Invite/ViewModels/InviteViewModels.swift +++ b/suixinkan/Features/Invite/ViewModels/InviteViewModels.swift @@ -7,22 +7,21 @@ import CoreImage.CIFilterBuiltins import Foundation -import Observation +import Combine import UIKit /// 邀请页 ViewModel,负责加载邀请信息、生成二维码和复制内容。 @MainActor -@Observable -final class PhotographerInviteViewModel { - private(set) var loading = false - private(set) var inviteCode = "" - private(set) var inviteUrl = "" - private(set) var rules: [String] = [] - private(set) var qrImage: UIImage? - var errorMessage: String? +final class PhotographerInviteViewModel: ObservableObject { + @Published private(set) var loading = false + @Published private(set) var inviteCode = "" + @Published private(set) var inviteUrl = "" + @Published private(set) var rules: [String] = [] + @Published private(set) var qrImage: UIImage? + @Published var errorMessage: String? - @ObservationIgnored private let qrContext = CIContext() - @ObservationIgnored private let qrFilter = CIFilter.qrCodeGenerator() + private let qrContext = CIContext() + private let qrFilter = CIFilter.qrCodeGenerator() /// 重新加载邀请信息。 func reload(api: any InviteServing) async { @@ -72,21 +71,20 @@ final class PhotographerInviteViewModel { /// 邀请记录 ViewModel,负责邀请用户、奖励明细和钱包汇总分页。 @MainActor -@Observable -final class InviteRecordViewModel { - var tab: InviteRecordTab = .invite - private(set) var totalRewardText = "¥ 0.00" - private(set) var withdrawableText = "¥ 0.00" - private(set) var displayRows: [InviteDisplayRow] = [] - private(set) var loading = false - private(set) var loadingMore = false - private(set) var hasMore = false - var errorMessage: String? +final class InviteRecordViewModel: ObservableObject { + @Published var tab: InviteRecordTab = .invite + @Published private(set) var totalRewardText = "¥ 0.00" + @Published private(set) var withdrawableText = "¥ 0.00" + @Published private(set) var displayRows: [InviteDisplayRow] = [] + @Published private(set) var loading = false + @Published private(set) var loadingMore = false + @Published private(set) var hasMore = false + @Published var errorMessage: String? - private var inviteRows: [InviteDisplayRow] = [] - private var rewardRows: [InviteDisplayRow] = [] - private var invitePage = 1 - private var rewardPage = 1 + @Published private var inviteRows: [InviteDisplayRow] = [] + @Published private var rewardRows: [InviteDisplayRow] = [] + @Published private var invitePage = 1 + @Published private var rewardPage = 1 private let invitePageSize = 20 private let rewardPageSize = 10 diff --git a/suixinkan/Features/Invite/Views/InviteViews.swift b/suixinkan/Features/Invite/Views/InviteViews.swift index 7e29084..64e0587 100644 --- a/suixinkan/Features/Invite/Views/InviteViews.swift +++ b/suixinkan/Features/Invite/Views/InviteViews.swift @@ -9,10 +9,10 @@ import SwiftUI /// 摄影师邀请页面,展示邀请二维码、邀请码、规则和邀请记录入口。 struct PhotographerInviteView: View { - @Environment(InviteAPI.self) private var inviteAPI - @Environment(ToastCenter.self) private var toastCenter + @Environment(\.inviteAPI) private var inviteAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = PhotographerInviteViewModel() + @StateObject private var viewModel = PhotographerInviteViewModel() var body: some View { ScrollView { @@ -140,10 +140,10 @@ struct PhotographerInviteView: View { /// 邀请记录页面,展示邀请用户和奖励记录。 struct InviteRecordView: View { - @Environment(InviteAPI.self) private var inviteAPI - @Environment(WalletAPI.self) private var walletAPI + @Environment(\.inviteAPI) private var inviteAPI + @Environment(\.walletAPI) private var walletAPI @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = InviteRecordViewModel() + @StateObject private var viewModel = InviteRecordViewModel() var body: some View { VStack(spacing: 0) { @@ -155,7 +155,7 @@ struct InviteRecordView: View { } .pickerStyle(.segmented) .padding(AppMetrics.Spacing.medium) - .onChange(of: viewModel.tab) { _, newValue in + .onChange(of: viewModel.tab) { newValue in Task { await viewModel.selectTab(newValue, inviteAPI: inviteAPI, walletAPI: walletAPI) } } recordList @@ -205,7 +205,7 @@ struct InviteRecordView: View { ScrollView { LazyVStack(spacing: 0) { if viewModel.displayRows.isEmpty && !viewModel.loading { - ContentUnavailableView("暂无记录", systemImage: "tray") + AppContentUnavailableView("暂无记录", systemImage: "tray") .frame(minHeight: 280) } ForEach(viewModel.displayRows) { row in diff --git a/suixinkan/Features/Live/API/LiveAPI.swift b/suixinkan/Features/Live/API/LiveAPI.swift index ca78d5a..c2dba78 100644 --- a/suixinkan/Features/Live/API/LiveAPI.swift +++ b/suixinkan/Features/Live/API/LiveAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 直播服务协议,定义直播管理和直播相册接口能力。 @MainActor @@ -26,10 +26,9 @@ protocol LiveServing { } @MainActor -@Observable /// 直播 API,封装手动直播和直播相册网络请求。 final class LiveAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化直播 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift b/suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift index 2f0f8ee..a8069b4 100644 --- a/suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift +++ b/suixinkan/Features/Live/ViewModels/LivePlaybackViewModel.swift @@ -7,7 +7,7 @@ import AVFoundation import Foundation -import Observation +import Combine /// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。 enum LivePlaybackURLResolver { @@ -44,13 +44,12 @@ enum LivePlaybackState: Equatable { } @MainActor -@Observable /// 直播播放器 ViewModel,管理系统播放器的 URL、播放和释放状态。 -final class LivePlaybackViewModel { - var state: LivePlaybackState = .empty - var errorMessage: String? +final class LivePlaybackViewModel: ObservableObject { + @Published var state: LivePlaybackState = .empty + @Published var errorMessage: String? - @ObservationIgnored private(set) var player: AVPlayer? + @Published private(set) var player: AVPlayer? var playableURL: URL? { switch state { diff --git a/suixinkan/Features/Live/ViewModels/LivePushReadinessViewModel.swift b/suixinkan/Features/Live/ViewModels/LivePushReadinessViewModel.swift index 1268d95..61536b9 100644 --- a/suixinkan/Features/Live/ViewModels/LivePushReadinessViewModel.swift +++ b/suixinkan/Features/Live/ViewModels/LivePushReadinessViewModel.swift @@ -8,7 +8,7 @@ import AVFoundation import Foundation import Network -import Observation +import Combine /// 直播推流权限状态。 enum LivePushPermissionState: Equatable { @@ -158,23 +158,22 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { } @MainActor -@Observable /// 推流准备 ViewModel,检查权限、网络和默认 SDK 可用性。 -final class LivePushReadinessViewModel { - var cameraPermission: LivePushPermissionState = .unknown - var microphonePermission: LivePushPermissionState = .unknown - var networkState: LivePushNetworkState = .unknown - var prepared = false - var running = false - var errorMessage: String? +final class LivePushReadinessViewModel: ObservableObject { + @Published var cameraPermission: LivePushPermissionState = .unknown + @Published var microphonePermission: LivePushPermissionState = .unknown + @Published var networkState: LivePushNetworkState = .unknown + @Published var prepared = false + @Published var running = false + @Published var errorMessage: String? let adapterName: String let sdkStatusText: String - @ObservationIgnored private let permissionProvider: any LivePermissionProviding - @ObservationIgnored private let networkMonitor: any LiveNetworkMonitoring - @ObservationIgnored private let adapter: any LivePushAdapter - @ObservationIgnored private var pushURL: URL? + private let permissionProvider: any LivePermissionProviding + private let networkMonitor: any LiveNetworkMonitoring + private let adapter: any LivePushAdapter + @Published private var pushURL: URL? init( permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(), diff --git a/suixinkan/Features/Live/ViewModels/LiveViewModels.swift b/suixinkan/Features/Live/ViewModels/LiveViewModels.swift index ca77e69..e92ed91 100644 --- a/suixinkan/Features/Live/ViewModels/LiveViewModels.swift +++ b/suixinkan/Features/Live/ViewModels/LiveViewModels.swift @@ -6,22 +6,21 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 直播管理 ViewModel,负责直播列表、详情、创建和控制动作。 -final class LiveManagementViewModel { - var items: [LiveEntity] = [] - var detail: LiveEntity? - var loading = false - var loadingMore = false - var hasMore = false - var page = 1 - var errorMessage: String? +final class LiveManagementViewModel: ObservableObject { + @Published var items: [LiveEntity] = [] + @Published var detail: LiveEntity? + @Published var loading = false + @Published var loadingMore = false + @Published var hasMore = false + @Published var page = 1 + @Published var errorMessage: String? - @ObservationIgnored private let pageSize = 10 - @ObservationIgnored private var total = 0 + private let pageSize = 10 + @Published private var total = 0 /// 进行中的直播数量。 var liveRunningCount: Int { @@ -136,13 +135,12 @@ final class LiveManagementViewModel { } @MainActor -@Observable /// 直播详情 ViewModel,负责直播详情页动作后的详情刷新。 -final class LiveDetailViewModel { - var detail: LiveEntity - var loading = false - var actionInFlight = false - var errorMessage: String? +final class LiveDetailViewModel: ObservableObject { + @Published var detail: LiveEntity + @Published var loading = false + @Published var actionInFlight = false + @Published var errorMessage: String? init(detail: LiveEntity) { self.detail = detail @@ -192,20 +190,19 @@ final class LiveDetailViewModel { } @MainActor -@Observable /// 直播相册 ViewModel,负责相册列表、筛选、新建和删除。 -final class LiveAlbumViewModel { - var folders: [LiveAlbumFolderItem] = [] - var startDate: Date? - var endDate: Date? - var loading = false - var loadingMore = false - var hasMore = false - var page = 1 - var errorMessage: String? +final class LiveAlbumViewModel: ObservableObject { + @Published var folders: [LiveAlbumFolderItem] = [] + @Published var startDate: Date? + @Published var endDate: Date? + @Published var loading = false + @Published var loadingMore = false + @Published var hasMore = false + @Published var page = 1 + @Published var errorMessage: String? - @ObservationIgnored private let pageSize = 10 - @ObservationIgnored private var total = 0 + private let pageSize = 10 + @Published private var total = 0 /// 设置开始时间并校验时间顺序。 func setStartDate(_ date: Date) throws { @@ -301,14 +298,13 @@ final class LiveAlbumViewModel { } @MainActor -@Observable /// 新建直播相册 ViewModel,负责本地素材上传和创建相册。 -final class LiveAlbumCreateViewModel { - var name = "" - var localFiles: [LiveAlbumLocalUploadFile] = [] - var submitting = false - var uploadProgress = 0 - var errorMessage: String? +final class LiveAlbumCreateViewModel: ObservableObject { + @Published var name = "" + @Published var localFiles: [LiveAlbumLocalUploadFile] = [] + @Published var submitting = false + @Published var uploadProgress = 0 + @Published var errorMessage: String? /// 添加本地待上传素材。 func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) { @@ -368,15 +364,14 @@ final class LiveAlbumCreateViewModel { } @MainActor -@Observable /// 直播相册预览 ViewModel,负责加载相册详情和删除当前素材。 -final class LiveAlbumPreviewViewModel { +final class LiveAlbumPreviewViewModel: ObservableObject { let folderId: Int - var folder: LiveAlbumFolderItem? - var files: [LiveAlbumFileItem] = [] - var currentIndex: Int - var loading = false - var errorMessage: String? + @Published var folder: LiveAlbumFolderItem? + @Published var files: [LiveAlbumFileItem] = [] + @Published var currentIndex: Int + @Published var loading = false + @Published var errorMessage: String? init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) { self.folderId = folderId diff --git a/suixinkan/Features/Live/Views/LiveAlbumViews.swift b/suixinkan/Features/Live/Views/LiveAlbumViews.swift index 065bef3..65e9437 100644 --- a/suixinkan/Features/Live/Views/LiveAlbumViews.swift +++ b/suixinkan/Features/Live/Views/LiveAlbumViews.swift @@ -12,12 +12,12 @@ import UniformTypeIdentifiers /// 直播相册首页,展示相册列表、筛选和上传入口。 struct LiveAlbumView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(LiveAPI.self) private var liveAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.liveAPI) private var liveAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = LiveAlbumViewModel() + @StateObject private var viewModel = LiveAlbumViewModel() @State private var showStartPicker = false @State private var showEndPicker = false @State private var showCreatePage = false @@ -29,7 +29,7 @@ struct LiveAlbumView: View { ScrollView { LazyVStack(spacing: AppMetrics.Spacing.medium) { if viewModel.folders.isEmpty { - ContentUnavailableView("暂无素材", systemImage: "photo.stack") + AppContentUnavailableView("暂无素材", systemImage: "photo.stack") .frame(maxWidth: .infinity, minHeight: 300) } else { ForEach(viewModel.folders) { folder in @@ -286,13 +286,13 @@ private struct LiveDatePickerSheet: View { /// 新建直播相册页面,支持选择本地图片/视频并上传。 struct LiveAlbumCreateView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(LiveAPI.self) private var liveAPI - @Environment(OSSUploadService.self) private var uploadService - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.liveAPI) private var liveAPI + @Environment(\.ossUploadService) private var uploadService + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss - @State private var viewModel = LiveAlbumCreateViewModel() + @StateObject private var viewModel = LiveAlbumCreateViewModel() @State private var pickerItems: [PhotosPickerItem] = [] let onCreated: () -> Void @@ -330,7 +330,7 @@ struct LiveAlbumCreateView: View { .background(Color(hex: 0xF5F7FA)) .navigationTitle("上传素材") .navigationBarTitleDisplayMode(.inline) - .onChange(of: pickerItems) { _, newItems in + .onChange(of: pickerItems) { newItems in Task { await importPickerItems(newItems) } } } @@ -338,7 +338,7 @@ struct LiveAlbumCreateView: View { @ViewBuilder private var localFileGrid: some View { if viewModel.localFiles.isEmpty { - ContentUnavailableView("未选择素材", systemImage: "photo") + AppContentUnavailableView("未选择素材", systemImage: "photo") .frame(maxWidth: .infinity, minHeight: 180) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } else { @@ -392,21 +392,21 @@ struct LiveAlbumCreateView: View { /// 直播相册预览页面。 struct LiveAlbumPreviewView: View { - @Environment(LiveAPI.self) private var liveAPI - @Environment(ToastCenter.self) private var toastCenter + @Environment(\.liveAPI) private var liveAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss - @State private var viewModel: LiveAlbumPreviewViewModel + @StateObject private var viewModel: LiveAlbumPreviewViewModel @State private var showDeleteConfirm = false init(folderId: Int, startIndex: Int, summary: LiveAlbumFolderItem?) { - _viewModel = State(initialValue: LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex, summary: summary)) + _viewModel = StateObject(wrappedValue: LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex, summary: summary)) } var body: some View { VStack(spacing: 0) { if viewModel.files.isEmpty { - ContentUnavailableView("没有可预览的素材", systemImage: "photo") + AppContentUnavailableView("没有可预览的素材", systemImage: "photo") .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black) } else { @@ -459,12 +459,12 @@ struct LiveAlbumPreviewView: View { private struct LiveAlbumPreviewPage: View { let file: LiveAlbumFileItem - @Environment(ToastCenter.self) private var toastCenter - @State private var playbackViewModel: LivePlaybackViewModel + @EnvironmentObject private var toastCenter: ToastCenter + @StateObject private var playbackViewModel: LivePlaybackViewModel init(file: LiveAlbumFileItem) { self.file = file - _playbackViewModel = State(initialValue: LivePlaybackViewModel(urlString: file.url)) + _playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(urlString: file.url)) } var body: some View { diff --git a/suixinkan/Features/Live/Views/LiveManagementViews.swift b/suixinkan/Features/Live/Views/LiveManagementViews.swift index f0bb900..3a68c64 100644 --- a/suixinkan/Features/Live/Views/LiveManagementViews.swift +++ b/suixinkan/Features/Live/Views/LiveManagementViews.swift @@ -10,12 +10,12 @@ import SwiftUI /// 直播管理首页,展示直播列表和控制入口。 struct LiveManagementView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(LiveAPI.self) private var liveAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.liveAPI) private var liveAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = LiveManagementViewModel() + @StateObject private var viewModel = LiveManagementViewModel() @State private var showAddPage = false var body: some View { @@ -24,7 +24,7 @@ struct LiveManagementView: View { ScrollView { LazyVStack(spacing: AppMetrics.Spacing.medium) { if viewModel.items.isEmpty { - ContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right") + AppContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right") .frame(maxWidth: .infinity, minHeight: 280) } else { ForEach(viewModel.items) { item in @@ -231,9 +231,9 @@ private struct LiveSmallAction: View { /// 添加直播页面。 struct LiveAddView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(LiveAPI.self) private var liveAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.liveAPI) private var liveAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @State private var title = "" @@ -351,18 +351,18 @@ struct LiveAddView: View { /// 直播详情页面,展示推流信息和控制动作。 struct LiveDetailView: View { - @Environment(LiveAPI.self) private var liveAPI - @Environment(ToastCenter.self) private var toastCenter + @Environment(\.liveAPI) private var liveAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel: LiveDetailViewModel - @State private var playbackViewModel: LivePlaybackViewModel - @State private var pushReadinessViewModel = LivePushReadinessViewModel() + @StateObject private var viewModel: LiveDetailViewModel + @StateObject private var playbackViewModel: LivePlaybackViewModel + @StateObject private var pushReadinessViewModel = LivePushReadinessViewModel() @State private var copied = false let onChanged: () -> Void init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) { - _viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail)) - _playbackViewModel = State(initialValue: LivePlaybackViewModel(live: initialDetail)) + _viewModel = StateObject(wrappedValue: LiveDetailViewModel(detail: initialDetail)) + _playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(live: initialDetail)) self.onChanged = onChanged } diff --git a/suixinkan/Features/LocationReport/API/LocationReportAPI.swift b/suixinkan/Features/LocationReport/API/LocationReportAPI.swift index b6d7823..f175670 100644 --- a/suixinkan/Features/LocationReport/API/LocationReportAPI.swift +++ b/suixinkan/Features/LocationReport/API/LocationReportAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 位置上报服务协议,抽象上报和历史接口以便 ViewModel 测试替换。 @MainActor @@ -20,9 +20,8 @@ protocol LocationReportServing { /// 位置上报 API,负责封装旧工程定位上报相关接口。 @MainActor -@Observable final class LocationReportAPI: LocationReportServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化位置上报 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift index bfc2dd6..64eaeae 100644 --- a/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift +++ b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift @@ -6,22 +6,21 @@ // import Foundation -import Observation +import Combine /// 位置上报 ViewModel,负责页面内在线状态、坐标、倒计时和提交动作。 @MainActor -@Observable -final class LocationReportViewModel { - var isOnline = false - var reminderMinutes = 30 - var secondsUntilReport = 0 - var currentCoordinate: LocationCoordinate? - var markedCoordinate: LocationCoordinate? - var currentAddress = "" - var markedAddress = "" - var lastReportText = "" - var errorMessage: String? - var isSubmitting = false +final class LocationReportViewModel: ObservableObject { + @Published var isOnline = false + @Published var reminderMinutes = 30 + @Published var secondsUntilReport = 0 + @Published var currentCoordinate: LocationCoordinate? + @Published var markedCoordinate: LocationCoordinate? + @Published var currentAddress = "" + @Published var markedAddress = "" + @Published var lastReportText = "" + @Published var errorMessage: String? + @Published var isSubmitting = false /// 倒计时展示文案。 var countdownText: String { @@ -130,18 +129,17 @@ final class LocationReportViewModel { /// 位置上报历史 ViewModel,负责筛选、日期和分页加载。 @MainActor -@Observable -final class LocationReportHistoryViewModel { - var selectedType: LocationReportType = .all - var startDate: Date? - var endDate: Date? - var items: [LocationReportHistoryItem] = [] - var errorMessage: String? - var isLoading = false - var isLoadingMore = false - var total = 0 +final class LocationReportHistoryViewModel: ObservableObject { + @Published var selectedType: LocationReportType = .all + @Published var startDate: Date? + @Published var endDate: Date? + @Published var items: [LocationReportHistoryItem] = [] + @Published var errorMessage: String? + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var total = 0 - private var page = 1 + @Published private var page = 1 private let pageSize = 20 /// 是否还有下一页历史记录。 diff --git a/suixinkan/Features/LocationReport/Views/LocationReportViews.swift b/suixinkan/Features/LocationReport/Views/LocationReportViews.swift index b88a452..45444b4 100644 --- a/suixinkan/Features/LocationReport/Views/LocationReportViews.swift +++ b/suixinkan/Features/LocationReport/Views/LocationReportViews.swift @@ -10,14 +10,14 @@ import SwiftUI /// 位置上报页面,支持当前位置上报、标记点上报、在线状态和提醒设置。 struct LocationReportView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(AccountSnapshotStore.self) private var snapshotStore - @Environment(LocationReportAPI.self) private var locationReportAPI - @Environment(RouterPath.self) private var router - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.accountSnapshotStore) private var snapshotStore + @Environment(\.locationReportAPI) private var locationReportAPI + @EnvironmentObject private var router: RouterPath + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = LocationReportViewModel() + @StateObject private var viewModel = LocationReportViewModel() @State private var locationProvider = ForegroundLocationProvider() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -242,12 +242,12 @@ struct LocationReportView: View { /// 位置上报历史页面,展示历史记录筛选和分页。 struct LocationReportHistoryView: View { - @Environment(AccountSnapshotStore.self) private var snapshotStore - @Environment(LocationReportAPI.self) private var locationReportAPI - @Environment(ToastCenter.self) private var toastCenter + @Environment(\.accountSnapshotStore) private var snapshotStore + @Environment(\.locationReportAPI) private var locationReportAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = LocationReportHistoryViewModel() + @StateObject private var viewModel = LocationReportHistoryViewModel() var body: some View { ScrollView { @@ -277,7 +277,7 @@ struct LocationReportHistoryView: View { } } .pickerStyle(.segmented) - .onChange(of: viewModel.selectedType) { _, _ in + .onChange(of: viewModel.selectedType) { _ in Task { await reload(showLoading: true) } } diff --git a/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift b/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift index cfea9be..f687eef 100644 --- a/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift +++ b/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift @@ -6,13 +6,12 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 主 Tab 角标 ViewModel,负责获取订单 Tab 待核销数量。 -final class MainTabBadgeViewModel { - private(set) var pendingWriteOffCount: Int? +final class MainTabBadgeViewModel: ObservableObject { + @Published private(set) var pendingWriteOffCount: Int? /// 刷新待核销订单数量,缺少景区或接口失败时清空角标。 func refreshPendingWriteOffCount(api: OrderServing, scenicId: Int?, storeId: Int?) async { diff --git a/suixinkan/Features/Main/Views/MainTabsView.swift b/suixinkan/Features/Main/Views/MainTabsView.swift index 37599fd..68bef32 100644 --- a/suixinkan/Features/Main/Views/MainTabsView.swift +++ b/suixinkan/Features/Main/Views/MainTabsView.swift @@ -32,11 +32,9 @@ struct MainTabsView: View { /// 系统 TabView 外壳,保留原生 TabBar 行为以便后续切换回系统实现。 private struct SystemMainTabsView: View { - @Environment(AppRouter.self) private var appRouter + @EnvironmentObject private var appRouter: AppRouter var body: some View { - @Bindable var appRouter = appRouter - TabView(selection: $appRouter.selectedTab) { ForEach(AppTab.allCases) { tab in TabNavigationStackHost(tab: tab) @@ -49,12 +47,12 @@ private struct SystemMainTabsView: View { /// 自定义 TabBar 外壳,复用每个 Tab 独立 NavigationStack 并保留已访问页面状态。 private struct CustomMainTabsView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(AppRouter.self) private var appRouter - @Environment(OrdersAPI.self) private var ordersAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var appRouter: AppRouter + @Environment(\.ordersAPI) private var ordersAPI + @EnvironmentObject private var toastCenter: ToastCenter - @State private var badgeViewModel = MainTabBadgeViewModel() + @StateObject private var badgeViewModel = MainTabBadgeViewModel() @State private var showScanner = false @State private var loadedTabs: Set = [.home] @@ -66,8 +64,6 @@ private struct CustomMainTabsView: View { } var body: some View { - @Bindable var appRouter = appRouter - ZStack { ForEach(AppTab.allCases) { tab in PreservedTabPage( @@ -102,15 +98,15 @@ private struct CustomMainTabsView: View { loadedTabs.insert(appRouter.selectedTab) await refreshOrderBadge() } - .onChange(of: appRouter.selectedTab) { _, selectedTab in + .onChange(of: appRouter.selectedTab) { selectedTab in loadedTabs.insert(selectedTab) guard selectedTab == .orders else { return } Task { await refreshOrderBadge() } } - .onChange(of: accountContext.currentScenic?.id) { _, _ in + .onChange(of: accountContext.currentScenic?.id) { _ in Task { await refreshOrderBadge() } } - .onChange(of: accountContext.currentStore?.id) { _, _ in + .onChange(of: accountContext.currentStore?.id) { _ in Task { await refreshOrderBadge() } } } @@ -127,7 +123,7 @@ private struct CustomMainTabsView: View { /// 自定义 TabBar 模式下的单个 Tab 导航栈,TabBar 只属于根页面内容。 private struct CustomTabNavigationStackHost: View { - @Environment(AppRouter.self) private var appRouter + @EnvironmentObject private var appRouter: AppRouter let tab: AppTab @Binding var selectedTab: AppTab @@ -153,13 +149,13 @@ private struct CustomTabNavigationStackHost: View { .toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar) } } - .environment(appRouter.router(for: tab)) + .environmentObject(appRouter.router(for: tab)) } } /// 单个 Tab 对应的导航栈,供系统和自定义 TabBar 外壳共同复用。 private struct TabNavigationStackHost: View { - @Environment(AppRouter.self) private var appRouter + @EnvironmentObject private var appRouter: AppRouter let tab: AppTab @@ -172,7 +168,7 @@ private struct TabNavigationStackHost: View { .toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar) } } - .environment(appRouter.router(for: tab)) + .environmentObject(appRouter.router(for: tab)) } } @@ -197,8 +193,8 @@ private struct PreservedTabPage: View { #Preview { MainTabsView() - .environment(AppRouter()) - .environment(AccountContext()) - .environment(OrdersAPI(client: APIClient())) - .environment(ToastCenter()) + .environmentObject(AppRouter()) + .environmentObject(AccountContext()) + .environment(\.ordersAPI, OrdersAPI(client: APIClient())) + .environmentObject(ToastCenter()) } diff --git a/suixinkan/Features/Main/Views/PlaceholderRootViews.swift b/suixinkan/Features/Main/Views/PlaceholderRootViews.swift index 69459e1..0e6741a 100644 --- a/suixinkan/Features/Main/Views/PlaceholderRootViews.swift +++ b/suixinkan/Features/Main/Views/PlaceholderRootViews.swift @@ -37,7 +37,7 @@ struct ProfileRootView: View { /// 通用 Tab 占位视图,用于未迁移模块的临时入口。 private struct PlaceholderTabRootView: View { - @Environment(RouterPath.self) private var router + @EnvironmentObject private var router: RouterPath let title: String let systemImage: String diff --git a/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift b/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift index 9650aeb..9bb9cf7 100644 --- a/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift +++ b/suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 消息中心服务协议,定义消息列表、已读和删除能力。 @MainActor @@ -22,10 +22,9 @@ protocol MessageCenterServing { } @MainActor -@Observable /// 消息中心 API,封装消息相关网络请求。 final class MessageCenterAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化消息中心 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift index fb527f1..d8a88fb 100644 --- a/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift +++ b/suixinkan/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift @@ -6,22 +6,21 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 消息中心 ViewModel,负责消息分页、筛选、已读和删除。 -final class MessageCenterViewModel { - var messages: [MessageItem] = [] - var selectedFilter: MessageFilter = .all - var isLoading = false - var isLoadingMore = false - var loadFailed = false - var loadFailureReason: String? - var message: String? +final class MessageCenterViewModel: ObservableObject { + @Published var messages: [MessageItem] = [] + @Published var selectedFilter: MessageFilter = .all + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var loadFailed = false + @Published var loadFailureReason: String? + @Published var message: String? - private(set) var hasMoreMessages = false - private(set) var lastId = 0 + @Published private(set) var hasMoreMessages = false + @Published private(set) var lastId = 0 private let pageSize = 20 /// 未读消息数量。 diff --git a/suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift b/suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift index 360a185..591d701 100644 --- a/suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift +++ b/suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift @@ -9,9 +9,9 @@ import SwiftUI /// 消息中心页面,展示消息列表、筛选、已读和删除入口。 struct MessageCenterView: View { - @Environment(MessageCenterAPI.self) private var messageAPI - @Environment(ToastCenter.self) private var toastCenter - @State private var viewModel = MessageCenterViewModel() + @Environment(\.messageCenterAPI) private var messageAPI + @EnvironmentObject private var toastCenter: ToastCenter + @StateObject private var viewModel = MessageCenterViewModel() @State private var selectedMessage: MessageItem? @State private var processingMessageId: String? @@ -80,7 +80,7 @@ struct MessageCenterView: View { ProgressView("加载中...") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.loadFailed { - ContentUnavailableView { + AppContentUnavailableView { Label("消息加载失败", systemImage: "exclamationmark.triangle") } description: { Text(viewModel.loadFailureReason ?? "请稍后重试") @@ -92,7 +92,7 @@ struct MessageCenterView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.messages.isEmpty { - ContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息")) + AppContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息")) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { diff --git a/suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift b/suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift index 690feb7..29b0eeb 100644 --- a/suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift +++ b/suixinkan/Features/OperatingArea/API/OperatingAreaAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。 @MainActor @@ -16,10 +16,9 @@ protocol OperatingAreaServing { } @MainActor -@Observable /// 运营区域 API,封装运营区域只读围栏接口。 final class OperatingAreaAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化运营区域 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift b/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift index bd2d524..0523a98 100644 --- a/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift +++ b/suixinkan/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift @@ -6,18 +6,17 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 运营区域 ViewModel,负责角色分流、围栏加载、解析和错误状态管理。 -final class OperatingAreaViewModel { - private(set) var mode: OperatingAreaEntryMode? - private(set) var loading = false - private(set) var items: [OperatingAreaItem] = [] - private(set) var fenceRings: [OperatingFenceRing] = [] - private(set) var blockReason: OperatingAreaBlockReason? - private(set) var errorMessage: String? +final class OperatingAreaViewModel: ObservableObject { + @Published private(set) var mode: OperatingAreaEntryMode? + @Published private(set) var loading = false + @Published private(set) var items: [OperatingAreaItem] = [] + @Published private(set) var fenceRings: [OperatingFenceRing] = [] + @Published private(set) var blockReason: OperatingAreaBlockReason? + @Published private(set) var errorMessage: String? /// 按当前账号和角色刷新运营区域。 func reload( diff --git a/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift b/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift index 76d09ef..534b579 100644 --- a/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift +++ b/suixinkan/Features/OperatingArea/Views/OperatingAreaViews.swift @@ -14,13 +14,13 @@ import MAMapKit /// 运营区域首页,按当前角色展示店铺或景区运营围栏。 struct OperatingAreaView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(PermissionContext.self) private var permissionContext - @Environment(OperatingAreaAPI.self) private var operatingAreaAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var permissionContext: PermissionContext + @Environment(\.operatingAreaAPI) private var operatingAreaAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = OperatingAreaViewModel() + @StateObject private var viewModel = OperatingAreaViewModel() var body: some View { ScrollView { @@ -87,7 +87,7 @@ struct OperatingAreaView: View { } if let reason = viewModel.blockReason { - ContentUnavailableView(reason.message, systemImage: "mappin.slash") + AppContentUnavailableView(reason.message, systemImage: "mappin.slash") .frame(maxWidth: .infinity, minHeight: 260) .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) .overlay(alignment: .bottom) { diff --git a/suixinkan/Features/Orders/API/OrdersAPI.swift b/suixinkan/Features/Orders/API/OrdersAPI.swift index 23ca3a2..4100c59 100644 --- a/suixinkan/Features/Orders/API/OrdersAPI.swift +++ b/suixinkan/Features/Orders/API/OrdersAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine @MainActor /// 订单服务协议,抽象订单列表、核销列表和核销操作以便测试替换。 @@ -59,10 +59,9 @@ protocol OrderServing { } @MainActor -@Observable /// 订单 API,封装订单管理、核销订单和订单核销接口。 final class OrdersAPI: OrderServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化订单 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift index 6590381..f6c65e9 100644 --- a/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/OrderDetailViewModel.swift @@ -6,16 +6,15 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 订单详情 ViewModel,负责加载门店订单详情并保留列表摘要兜底展示。 -final class OrderDetailViewModel { - private(set) var detail: StoreOrderDetailResponse? - private(set) var loading = false - private(set) var errorMessage: String? - private(set) var contextMessage: String? +final class OrderDetailViewModel: ObservableObject { + @Published private(set) var detail: StoreOrderDetailResponse? + @Published private(set) var loading = false + @Published private(set) var errorMessage: String? + @Published private(set) var contextMessage: String? /// 当前页面用于展示的订单信息,详情接口成功时优先使用详情数据。 var display: StoreOrderDetailDisplay { diff --git a/suixinkan/Features/Orders/ViewModels/OrderLongTailViewModels.swift b/suixinkan/Features/Orders/ViewModels/OrderLongTailViewModels.swift index ffa1060..ed034db 100644 --- a/suixinkan/Features/Orders/ViewModels/OrderLongTailViewModels.swift +++ b/suixinkan/Features/Orders/ViewModels/OrderLongTailViewModels.swift @@ -6,20 +6,19 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。 -final class DepositOrderListViewModel { - private(set) var orders: [DepositOrderListItem] = [] - private(set) var total = 0 - private(set) var page = 1 - private(set) var hasMore = false - private(set) var loading = false - private(set) var loadingMore = false - private(set) var operatingOrderNumber: String? - var errorMessage: String? +final class DepositOrderListViewModel: ObservableObject { + @Published private(set) var orders: [DepositOrderListItem] = [] + @Published private(set) var total = 0 + @Published private(set) var page = 1 + @Published private(set) var hasMore = false + @Published private(set) var loading = false + @Published private(set) var loadingMore = false + @Published private(set) var operatingOrderNumber: String? + @Published var errorMessage: String? private let pageSize = 10 @@ -134,12 +133,11 @@ final class DepositOrderListViewModel { } @MainActor -@Observable /// 押金订单详情 ViewModel,负责按门店和订单号加载详情。 -final class DepositOrderDetailViewModel { - private(set) var detail: StoreOrderDetailResponse? - private(set) var loading = false - var errorMessage: String? +final class DepositOrderDetailViewModel: ObservableObject { + @Published private(set) var detail: StoreOrderDetailResponse? + @Published private(set) var loading = false + @Published var errorMessage: String? /// 加载押金订单详情,缺少门店或订单号时不请求接口。 func load(api: OrderServing, storeId: Int?, orderNumber: String) async { @@ -179,12 +177,11 @@ final class DepositOrderDetailViewModel { } @MainActor -@Observable /// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。 -final class DepositOrderShootingInfoViewModel { - private(set) var detail: StoreOrderShootingDetailResponse? - private(set) var loading = false - var errorMessage: String? +final class DepositOrderShootingInfoViewModel: ObservableObject { + @Published private(set) var detail: StoreOrderShootingDetailResponse? + @Published private(set) var loading = false + @Published var errorMessage: String? /// 加载押金订单拍摄信息,缺少门店或订单号时不请求接口。 func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async { @@ -229,14 +226,13 @@ final class DepositOrderShootingInfoViewModel { } @MainActor -@Observable /// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。 -final class OrderRefundViewModel { - var mode: OrderRefundMode = .full - var amount = "" - var reason = "" - private(set) var submitting = false - var errorMessage: String? +final class OrderRefundViewModel: ObservableObject { + @Published var mode: OrderRefundMode = .full + @Published var amount = "" + @Published var reason = "" + @Published private(set) var submitting = false + @Published var errorMessage: String? /// 判断订单是否允许展示普通退款入口。 func canRefund(_ item: OrderEntity) -> Bool { @@ -344,14 +340,13 @@ final class OrderRefundViewModel { } @MainActor -@Observable /// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。 -final class HistoricalShootingInfoViewModel { - private(set) var projectName = "" - private(set) var projectTypeName = "" - private(set) var spots: [PhotogSpotItem] = [] - private(set) var loading = false - var errorMessage: String? +final class HistoricalShootingInfoViewModel: ObservableObject { + @Published private(set) var projectName = "" + @Published private(set) var projectTypeName = "" + @Published private(set) var spots: [PhotogSpotItem] = [] + @Published private(set) var loading = false + @Published var errorMessage: String? /// 加载历史拍摄信息,订单号为空时清空旧数据且不请求接口。 func load(api: OrderServing, orderNumber: String) async { @@ -390,20 +385,19 @@ final class HistoricalShootingInfoViewModel { } @MainActor -@Observable /// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。 -final class MultiTravelTaskUploadViewModel { - var orderNumber = "" - var selectedSpotId: Int? - private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = [] - var selectedCloudFiles: [TaskCloudSelectionItem] = [] - var selectedLocalFiles: [TaskLocalUploadItem] = [] - private(set) var isLoadingSpots = false - private(set) var isSubmitting = false - private(set) var didSubmitSuccessfully = false - var errorMessage: String? +final class MultiTravelTaskUploadViewModel: ObservableObject { + @Published var orderNumber = "" + @Published var selectedSpotId: Int? + @Published private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = [] + @Published var selectedCloudFiles: [TaskCloudSelectionItem] = [] + @Published var selectedLocalFiles: [TaskLocalUploadItem] = [] + @Published private(set) var isLoadingSpots = false + @Published private(set) var isSubmitting = false + @Published private(set) var didSubmitSuccessfully = false + @Published var errorMessage: String? - private var loadedSpotOrderNumber = "" + @Published private var loadedSpotOrderNumber = "" /// 当前选中打卡点展示名。 var selectedSpotName: String { diff --git a/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift index 21f941e..c3eb0aa 100644 --- a/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift @@ -6,31 +6,30 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 订单页面 ViewModel,管理订单列表、核销列表、筛选条件和手动核销流程。 -final class OrdersViewModel { - var selectedEntry: OrdersEntry = .storeOrders - var selectedStatus = 0 - var searchPhone = "" - var filterStartDate: Date? - var filterEndDate: Date? - private(set) var loading = false - private(set) var loadingMore = false - private(set) var isVerifying = false - private(set) var currentVerifyingOrderNumber: String? +final class OrdersViewModel: ObservableObject { + @Published var selectedEntry: OrdersEntry = .storeOrders + @Published var selectedStatus = 0 + @Published var searchPhone = "" + @Published var filterStartDate: Date? + @Published var filterEndDate: Date? + @Published private(set) var loading = false + @Published private(set) var loadingMore = false + @Published private(set) var isVerifying = false + @Published private(set) var currentVerifyingOrderNumber: String? - private(set) var storeOrders: [OrderEntity] = [] - private(set) var storeTotal = 0 - private(set) var storePage = 1 - private(set) var storeHasMore = false + @Published private(set) var storeOrders: [OrderEntity] = [] + @Published private(set) var storeTotal = 0 + @Published private(set) var storePage = 1 + @Published private(set) var storeHasMore = false - private(set) var writeOffOrders: [WriteOffOrderItem] = [] - private(set) var writeOffTotal = 0 - private(set) var writeOffPage = 1 - private(set) var writeOffHasMore = false + @Published private(set) var writeOffOrders: [WriteOffOrderItem] = [] + @Published private(set) var writeOffTotal = 0 + @Published private(set) var writeOffPage = 1 + @Published private(set) var writeOffHasMore = false /// 返回去除首尾空白后的手机号搜索值。 var normalizedPhone: String? { diff --git a/suixinkan/Features/Orders/Views/DepositOrderViews.swift b/suixinkan/Features/Orders/Views/DepositOrderViews.swift index 2eb6263..c7ab8a8 100644 --- a/suixinkan/Features/Orders/Views/DepositOrderViews.swift +++ b/suixinkan/Features/Orders/Views/DepositOrderViews.swift @@ -9,13 +9,13 @@ import SwiftUI /// 押金订单入口页,支持订单号查询、列表分页、核销和退款。 struct DepositOrderEntryView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(OrdersAPI.self) private var ordersAPI - @Environment(RouterPath.self) private var router - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.ordersAPI) private var ordersAPI + @EnvironmentObject private var router: RouterPath + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = DepositOrderListViewModel() + @StateObject private var viewModel = DepositOrderListViewModel() @State private var orderNumber = "" @State private var pendingWriteOff: DepositOrderListItem? @State private var pendingRefund: DepositOrderListItem? @@ -36,7 +36,7 @@ struct DepositOrderEntryView: View { Section { if viewModel.orders.isEmpty { - ContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass") + AppContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass") .frame(maxWidth: .infinity, minHeight: 180) } else { ForEach(viewModel.orders) { item in @@ -239,13 +239,13 @@ private struct DepositOrderRow: View { /// 押金订单详情页,展示订单基础信息和拍摄点列表。 struct DepositOrderDetailView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(OrdersAPI.self) private var ordersAPI - @Environment(RouterPath.self) private var router + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.ordersAPI) private var ordersAPI + @EnvironmentObject private var router: RouterPath @Environment(\.globalLoading) private var globalLoading let orderNumber: String - @State private var viewModel = DepositOrderDetailViewModel() + @StateObject private var viewModel = DepositOrderDetailViewModel() var body: some View { List { @@ -275,7 +275,7 @@ struct DepositOrderDetailView: View { Section("拍摄详情") { let shootingList = detail.multiTravel?.shootingList ?? [] if shootingList.isEmpty { - ContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack") + AppContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack") } else { ForEach(shootingList) { item in Button { @@ -296,7 +296,7 @@ struct DepositOrderDetailView: View { } } else { Section { - ContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass") + AppContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass") .frame(maxWidth: .infinity, minHeight: 220) } } @@ -363,15 +363,15 @@ private struct DepositShootingPointRow: View { /// 押金拍摄信息页,展示评价、底片和成片。 struct DepositOrderShootingInfoView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(OrdersAPI.self) private var ordersAPI + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.ordersAPI) private var ordersAPI @Environment(\.globalLoading) private var globalLoading let orderNumber: String let scenicSpotId: Int let photogUid: Int - @State private var viewModel = DepositOrderShootingInfoViewModel() + @StateObject private var viewModel = DepositOrderShootingInfoViewModel() @State private var selectedTab: DepositShootingMediaTab = .negative var body: some View { @@ -393,7 +393,7 @@ struct DepositOrderShootingInfoView: View { .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.textSecondary) } else { - ContentUnavailableView("暂无评价信息", systemImage: "star") + AppContentUnavailableView("暂无评价信息", systemImage: "star") } } @@ -410,7 +410,7 @@ struct DepositOrderShootingInfoView: View { } } else { Section { - ContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack") + AppContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack") .frame(maxWidth: .infinity, minHeight: 220) } } @@ -448,11 +448,11 @@ struct DepositOrderShootingInfoView: View { /// 历史拍摄信息页,展示多点位拍摄媒体。 struct HistoricalShootingInfoView: View { - @Environment(OrdersAPI.self) private var ordersAPI + @Environment(\.ordersAPI) private var ordersAPI @Environment(\.globalLoading) private var globalLoading let orderNumber: String - @State private var viewModel = HistoricalShootingInfoViewModel() + @StateObject private var viewModel = HistoricalShootingInfoViewModel() var body: some View { List { @@ -464,7 +464,7 @@ struct HistoricalShootingInfoView: View { Section("历史拍摄") { if viewModel.spots.isEmpty { - ContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle") + AppContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle") .frame(maxWidth: .infinity, minHeight: 220) } else { ForEach(viewModel.spots) { spot in @@ -565,7 +565,7 @@ private struct OrderMediaGrid: View { var body: some View { if files.isEmpty { - ContentUnavailableView("暂无媒体文件", systemImage: "photo") + AppContentUnavailableView("暂无媒体文件", systemImage: "photo") .frame(maxWidth: .infinity, minHeight: 140) } else { LazyVGrid(columns: columns, spacing: AppMetrics.Spacing.small) { diff --git a/suixinkan/Features/Orders/Views/OrderDetailViews.swift b/suixinkan/Features/Orders/Views/OrderDetailViews.swift index 25f5f4b..57ea209 100644 --- a/suixinkan/Features/Orders/Views/OrderDetailViews.swift +++ b/suixinkan/Features/Orders/Views/OrderDetailViews.swift @@ -9,21 +9,21 @@ import SwiftUI /// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。 struct StoreOrderDetailView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(OrdersAPI.self) private var ordersAPI - @Environment(RouterPath.self) private var router - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.ordersAPI) private var ordersAPI + @EnvironmentObject private var router: RouterPath + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading let item: OrderEntity - @State private var viewModel: OrderDetailViewModel - @State private var refundViewModel = OrderRefundViewModel() + @StateObject private var viewModel: OrderDetailViewModel + @StateObject private var refundViewModel = OrderRefundViewModel() @State private var showRefundSheet = false /// 使用列表订单初始化详情页,并创建详情 ViewModel。 init(item: OrderEntity) { self.item = item - _viewModel = State(initialValue: OrderDetailViewModel(item: item)) + _viewModel = StateObject(wrappedValue: OrderDetailViewModel(item: item)) } var body: some View { @@ -204,7 +204,7 @@ struct StoreOrderDetailView: View { /// 普通订单退款表单,负责收集退款模式、金额和原因。 private struct OrderRefundSheet: View { let item: OrderEntity - @Bindable var viewModel: OrderRefundViewModel + @ObservedObject var viewModel: OrderRefundViewModel let onCancel: () -> Void let onSubmit: () -> Void @FocusState private var focusedField: RefundInputField? @@ -276,9 +276,9 @@ private enum RefundInputField: Hashable { /// 核销订单详情页,展示核销列表项信息并支持再次发起核销。 struct WriteOffOrderDetailView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(OrdersAPI.self) private var ordersAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.ordersAPI) private var ordersAPI + @EnvironmentObject private var toastCenter: ToastCenter let item: WriteOffOrderItem @State private var verifying = false diff --git a/suixinkan/Features/Orders/Views/OrderTailUploadViews.swift b/suixinkan/Features/Orders/Views/OrderTailUploadViews.swift index 7993566..489f9fa 100644 --- a/suixinkan/Features/Orders/Views/OrderTailUploadViews.swift +++ b/suixinkan/Features/Orders/Views/OrderTailUploadViews.swift @@ -11,21 +11,21 @@ import UniformTypeIdentifiers /// 多点旅拍任务上传页面,负责选择打卡点和素材后提交到订单接口。 struct MultiTravelTaskUploadView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(OrdersAPI.self) private var ordersAPI - @Environment(OSSUploadService.self) private var uploadService - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.ordersAPI) private var ordersAPI + @Environment(\.ossUploadService) private var uploadService + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading - @State private var viewModel: MultiTravelTaskUploadViewModel + @StateObject private var viewModel: MultiTravelTaskUploadViewModel @State private var showSpotPicker = false @State private var showCloudSelection = false @State private var pickerItems: [PhotosPickerItem] = [] /// 使用订单号初始化任务上传页面。 init(initialOrderNumber: String) { - _viewModel = State(initialValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)) + _viewModel = StateObject(wrappedValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)) } var body: some View { @@ -61,7 +61,7 @@ struct MultiTravelTaskUploadView: View { } Button("取消", role: .cancel) {} } - .onChange(of: pickerItems) { _, items in + .onChange(of: pickerItems) { items in Task { await importPickerItems(items) } } .alert("提示", isPresented: errorBinding) { diff --git a/suixinkan/Features/Orders/Views/OrdersView.swift b/suixinkan/Features/Orders/Views/OrdersView.swift index c41702d..92353cd 100644 --- a/suixinkan/Features/Orders/Views/OrdersView.swift +++ b/suixinkan/Features/Orders/Views/OrdersView.swift @@ -9,16 +9,16 @@ import SwiftUI /// 订单 Tab 根视图,展示订单管理和核销订单两个业务入口。 struct OrdersView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(PermissionContext.self) private var permissionContext - @Environment(AppRouter.self) private var appRouter - @Environment(RouterPath.self) private var router - @Environment(OrdersAPI.self) private var ordersAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var permissionContext: PermissionContext + @EnvironmentObject private var appRouter: AppRouter + @EnvironmentObject private var router: RouterPath + @Environment(\.ordersAPI) private var ordersAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var viewModel = OrdersViewModel() + @StateObject private var viewModel = OrdersViewModel() @State private var manualOrderNumber = "" @State private var showDateFilterSheet = false @State private var showScanner = false @@ -62,7 +62,7 @@ struct OrdersView: View { entrySection if currentScenicId == nil { - ContentUnavailableView( + AppContentUnavailableView( "缺少经营上下文", systemImage: "mountain.2", description: Text("请先在首页选择景区后查看订单。") @@ -80,7 +80,7 @@ struct OrdersView: View { .padding(.bottom, AppMetrics.Spacing.pageVertical) .frame(maxWidth: .infinity) } - .onChange(of: matchedScannedOrderNumber) { _, orderNumber in + .onChange(of: matchedScannedOrderNumber) { orderNumber in guard let orderNumber else { return } withAnimation(.easeInOut(duration: 0.25)) { scrollProxy.scrollTo(orderNumber, anchor: .center) @@ -96,15 +96,15 @@ struct OrdersView: View { await reload() await consumePendingScanCodeIfNeeded() } - .onChange(of: appRouter.selectedOrdersEntry) { _, entry in + .onChange(of: appRouter.selectedOrdersEntry) { entry in guard viewModel.selectedEntry != entry else { return } viewModel.selectedEntry = entry Task { await reload() } } - .onChange(of: appRouter.pendingOrderScanCode) { _, _ in + .onChange(of: appRouter.pendingOrderScanCode) { _ in Task { await consumePendingScanCodeIfNeeded() } } - .onChange(of: accountContext.currentScenic?.id) { _, _ in + .onChange(of: accountContext.currentScenic?.id) { _ in Task { await reload() } } .confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) { @@ -233,7 +233,7 @@ struct OrdersView: View { .frame(maxWidth: .infinity) .frame(minHeight: 260) } else if viewModel.storeOrders.isEmpty { - ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。")) + AppContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。")) .frame(minHeight: 260) } else { ForEach(viewModel.storeOrders) { item in @@ -306,7 +306,7 @@ struct OrdersView: View { .frame(maxWidth: .infinity) .frame(minHeight: 260) } else if viewModel.writeOffOrders.isEmpty { - ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。")) + AppContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。")) .frame(minHeight: 260) } else { ForEach(viewModel.writeOffOrders) { item in @@ -735,11 +735,11 @@ private struct OrdersDateFilterSheet: View { #Preview { NavigationStack { OrdersView() - .environment(AccountContext()) - .environment(PermissionContext()) - .environment(AppRouter()) - .environment(RouterPath()) - .environment(OrdersAPI(client: APIClient())) - .environment(ToastCenter()) + .environmentObject(AccountContext()) + .environmentObject(PermissionContext()) + .environmentObject(AppRouter()) + .environmentObject(RouterPath()) + .environment(\.ordersAPI, OrdersAPI(client: APIClient())) + .environmentObject(ToastCenter()) } } diff --git a/suixinkan/Features/Payment/API/PaymentAPI.swift b/suixinkan/Features/Payment/API/PaymentAPI.swift index a9231b7..e1cd828 100644 --- a/suixinkan/Features/Payment/API/PaymentAPI.swift +++ b/suixinkan/Features/Payment/API/PaymentAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 收款模块服务协议,定义收款码和收款记录接口能力。 @MainActor @@ -19,10 +19,9 @@ protocol PaymentServing { } @MainActor -@Observable /// 收款 API,封装首页“收款”模块需要的网络请求。 final class PaymentAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化收款 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift index b1fa072..c3eec91 100644 --- a/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift +++ b/suixinkan/Features/Payment/ViewModels/PaymentViewModel.swift @@ -8,25 +8,24 @@ import CoreImage import CoreImage.CIFilterBuiltins import Foundation -import Observation +import Combine import UIKit @MainActor -@Observable /// 收款页 ViewModel,管理收款码加载、动态金额二维码和到账轮询。 -final class PaymentCollectionViewModel { - var staticPayUrl = "" - var dynamicPayUrl = "" - var amountText = "" - var remarkText = "" - var currentPayUrl = "" - var qrImage: UIImage? - var isLoading = false - var isPolling = false - var errorMessage: String? - var status: PaymentCollectionStatus = .idle +final class PaymentCollectionViewModel: ObservableObject { + @Published var staticPayUrl = "" + @Published var dynamicPayUrl = "" + @Published var amountText = "" + @Published var remarkText = "" + @Published var currentPayUrl = "" + @Published var qrImage: UIImage? + @Published var isLoading = false + @Published var isPolling = false + @Published var errorMessage: String? + @Published var status: PaymentCollectionStatus = .idle - private var knownRecordIDs = Set() + @Published private var knownRecordIDs = Set() private let context = CIContext() private let qrFilter = CIFilter.qrCodeGenerator() @@ -191,12 +190,11 @@ private extension String { } @MainActor -@Observable /// 收款记录 ViewModel,管理收款记录加载、日期分组和汇总兜底。 -final class PaymentCollectionRecordViewModel { - var groups: [PaymentCollectionRecordGroup] = [] - var isLoading = false - var errorMessage: String? +final class PaymentCollectionRecordViewModel: ObservableObject { + @Published var groups: [PaymentCollectionRecordGroup] = [] + @Published var isLoading = false + @Published var errorMessage: String? /// 加载当前景区收款记录,无景区时不请求接口。 func load(api: PaymentServing, scenicId: Int?) async { diff --git a/suixinkan/Features/Payment/Views/PaymentCollectionView.swift b/suixinkan/Features/Payment/Views/PaymentCollectionView.swift index 3bcf2bc..bef1757 100644 --- a/suixinkan/Features/Payment/Views/PaymentCollectionView.swift +++ b/suixinkan/Features/Payment/Views/PaymentCollectionView.swift @@ -11,12 +11,12 @@ import SwiftUI /// 收款页,展示静态收款码、动态金额收款和收款记录入口。 struct PaymentCollectionView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(PaymentAPI.self) private var paymentAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.paymentAPI) private var paymentAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = PaymentCollectionViewModel() + @StateObject private var viewModel = PaymentCollectionViewModel() @State private var showingAmountSheet = false @State private var pollingTask: Task? @State private var speaker = PaymentVoiceSpeaker() @@ -56,12 +56,12 @@ struct PaymentCollectionView: View { .onDisappear { pollingTask?.cancel() } - .onChange(of: viewModel.errorMessage) { _, message in + .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } } - .onChange(of: viewModel.status) { _, status in + .onChange(of: viewModel.status) { status in if case .success(let item) = status { speaker.speak("收款到账\(item.orderAmount)元") } @@ -256,11 +256,11 @@ struct PaymentCollectionView: View { /// 收款记录页,按日期展示扫码收款明细。 struct PaymentCollectionRecordView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(PaymentAPI.self) private var paymentAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.paymentAPI) private var paymentAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = PaymentCollectionRecordViewModel() + @StateObject private var viewModel = PaymentCollectionRecordViewModel() var body: some View { List { @@ -304,7 +304,7 @@ struct PaymentCollectionRecordView: View { await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id) } } - .onChange(of: viewModel.errorMessage) { _, message in + .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } @@ -314,9 +314,8 @@ struct PaymentCollectionRecordView: View { /// 收款语音播报服务,使用系统 TTS 播报到账提示。 @MainActor -@Observable final class PaymentVoiceSpeaker { - @ObservationIgnored private let synthesizer = AVSpeechSynthesizer() + private let synthesizer = AVSpeechSynthesizer() /// 播报指定中文文案。 func speak(_ text: String) { diff --git a/suixinkan/Features/PilotCertification/API/PilotCertificationAPI.swift b/suixinkan/Features/PilotCertification/API/PilotCertificationAPI.swift index 67e14da..ba50ee3 100644 --- a/suixinkan/Features/PilotCertification/API/PilotCertificationAPI.swift +++ b/suixinkan/Features/PilotCertification/API/PilotCertificationAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。 @MainActor @@ -18,10 +18,9 @@ protocol PilotCertificationServing { } @MainActor -@Observable /// 飞手认证 API,封装 `/api/app/flyer` 认证相关接口。 final class PilotCertificationAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化飞手认证 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift b/suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift index 17471d9..3f17f13 100644 --- a/suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift +++ b/suixinkan/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine @MainActor /// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。 @@ -17,29 +17,28 @@ protocol PilotRealNameServing { extension ProfileAPI: PilotRealNameServing {} @MainActor -@Observable /// 飞手认证 ViewModel,管理审核状态、表单、验证码、证件图上传和提交。 -final class PilotCertificationViewModel { - private(set) var flyer: FlyerDetailResponse? - private(set) var realNameInfo: RealNameInfo? - var name = "" - var certType: PilotCertType = .caac - var certNo = "" - var certImageUrl = "" - var startDate = Date() - var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date() - var droneModel = "" - var droneSerialNo = "" - var phone = "" - var verifyCode = "" - private(set) var pendingCertificateImageData: Data? - private(set) var pendingCertificateFileName: String? - private(set) var uploadProgress: Int? - private(set) var loading = false - private(set) var sendingCode = false - private(set) var submitting = false - private(set) var countdown = 0 - var statusMessage: String? +final class PilotCertificationViewModel: ObservableObject { + @Published private(set) var flyer: FlyerDetailResponse? + @Published private(set) var realNameInfo: RealNameInfo? + @Published var name = "" + @Published var certType: PilotCertType = .caac + @Published var certNo = "" + @Published var certImageUrl = "" + @Published var startDate = Date() + @Published var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date() + @Published var droneModel = "" + @Published var droneSerialNo = "" + @Published var phone = "" + @Published var verifyCode = "" + @Published private(set) var pendingCertificateImageData: Data? + @Published private(set) var pendingCertificateFileName: String? + @Published private(set) var uploadProgress: Int? + @Published private(set) var loading = false + @Published private(set) var sendingCode = false + @Published private(set) var submitting = false + @Published private(set) var countdown = 0 + @Published var statusMessage: String? /// 加载实名状态和飞手认证详情,单通道失败不清空另一通道数据。 func load(api: any PilotCertificationServing, realNameAPI: any PilotRealNameServing) async { diff --git a/suixinkan/Features/PilotCertification/Views/PilotCertificationViews.swift b/suixinkan/Features/PilotCertification/Views/PilotCertificationViews.swift index d5a724b..ac6f116 100644 --- a/suixinkan/Features/PilotCertification/Views/PilotCertificationViews.swift +++ b/suixinkan/Features/PilotCertification/Views/PilotCertificationViews.swift @@ -12,14 +12,14 @@ import UIKit /// 飞手认证页面,展示认证审核状态并支持提交/驳回后编辑。 struct PilotCertificationView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(ProfileAPI.self) private var profileAPI - @Environment(PilotCertificationAPI.self) private var pilotAPI - @Environment(OSSUploadService.self) private var ossUploadService - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.profileAPI) private var profileAPI + @Environment(\.pilotCertificationAPI) private var pilotAPI + @Environment(\.ossUploadService) private var ossUploadService + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = PilotCertificationViewModel() + @StateObject private var viewModel = PilotCertificationViewModel() @State private var selectedImageItem: PhotosPickerItem? private let countdownTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -55,7 +55,7 @@ struct PilotCertificationView: View { .onReceive(countdownTimer) { _ in viewModel.tickCountdown() } - .onChange(of: selectedImageItem) { _, item in + .onChange(of: selectedImageItem) { item in guard let item else { return } Task { await loadImage(item) } } diff --git a/suixinkan/Features/Profile/API/ProfileAPI.swift b/suixinkan/Features/Profile/API/ProfileAPI.swift index 58546f5..9aacfff 100644 --- a/suixinkan/Features/Profile/API/ProfileAPI.swift +++ b/suixinkan/Features/Profile/API/ProfileAPI.swift @@ -6,13 +6,12 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。 final class ProfileAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化个人信息 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift b/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift index bf106c6..a2abe77 100644 --- a/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/AccountSwitchViewModel.swift @@ -6,17 +6,16 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 账号切换 ViewModel,管理可切换账号列表、选择状态和提交状态。 -final class AccountSwitchViewModel { - private(set) var accounts: [AccountSwitchAccount] = [] - var selectedAccountId: String? - private(set) var loading = false - private(set) var switching = false - private var didLoad = false +final class AccountSwitchViewModel: ObservableObject { + @Published private(set) var accounts: [AccountSwitchAccount] = [] + @Published var selectedAccountId: String? + @Published private(set) var loading = false + @Published private(set) var switching = false + @Published private var didLoad = false /// 当前选中的账号。 var selectedAccount: AccountSwitchAccount? { diff --git a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift index 6dd9749..954cbf4 100644 --- a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift @@ -6,21 +6,20 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。 -final class ProfileViewModel { - var userInfo: UserInfoResponse? - var realNameInfo: RealNameInfo? - var isLoading = false - var isSaving = false - var isEditingProfile = false - var editingNickname = "" - private(set) var pendingAvatarData: Data? - private(set) var pendingAvatarFileName: String? - private(set) var avatarUploadProgress: Int? +final class ProfileViewModel: ObservableObject { + @Published var userInfo: UserInfoResponse? + @Published var realNameInfo: RealNameInfo? + @Published var isLoading = false + @Published var isSaving = false + @Published var isEditingProfile = false + @Published var editingNickname = "" + @Published private(set) var pendingAvatarData: Data? + @Published private(set) var pendingAvatarFileName: String? + @Published private(set) var avatarUploadProgress: Int? var displayNickname: String { nonEmpty(userInfo?.nickname) ?? "未设置昵称" diff --git a/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift index feab7d6..961e1f0 100644 --- a/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/RealNameAuthViewModel.swift @@ -6,31 +6,30 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。 -final class RealNameAuthViewModel { - private(set) var info: RealNameInfo? - var realName = "" - var idCardNo = "" - var smsCode = "" - var startDate = Date() - var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date() - var isLongValid = false - var frontUrl = "" - var backUrl = "" - private(set) var pendingFrontImageData: Data? - private(set) var pendingBackImageData: Data? - private(set) var frontUploadProgress: Int? - private(set) var backUploadProgress: Int? - private var pendingFrontFileName: String? - private var pendingBackFileName: String? - private(set) var loading = false - private(set) var sendingCode = false - private(set) var submitting = false - var statusMessage: String? +final class RealNameAuthViewModel: ObservableObject { + @Published private(set) var info: RealNameInfo? + @Published var realName = "" + @Published var idCardNo = "" + @Published var smsCode = "" + @Published var startDate = Date() + @Published var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date() + @Published var isLongValid = false + @Published var frontUrl = "" + @Published var backUrl = "" + @Published private(set) var pendingFrontImageData: Data? + @Published private(set) var pendingBackImageData: Data? + @Published private(set) var frontUploadProgress: Int? + @Published private(set) var backUploadProgress: Int? + @Published private var pendingFrontFileName: String? + @Published private var pendingBackFileName: String? + @Published private(set) var loading = false + @Published private(set) var sendingCode = false + @Published private(set) var submitting = false + @Published var statusMessage: String? /// 拉取实名认证信息,并回填表单。 func load(api: ProfileAPI) async throws { diff --git a/suixinkan/Features/Profile/Views/AccountSwitchView.swift b/suixinkan/Features/Profile/Views/AccountSwitchView.swift index 00f88c7..50b7b52 100644 --- a/suixinkan/Features/Profile/Views/AccountSwitchView.swift +++ b/suixinkan/Features/Profile/Views/AccountSwitchView.swift @@ -9,19 +9,19 @@ import SwiftUI /// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。 struct AccountSwitchView: View { - @Environment(AppSession.self) private var appSession - @Environment(AccountContext.self) private var accountContext - @Environment(PermissionContext.self) private var permissionContext - @Environment(AppRouter.self) private var appRouter - @Environment(ProfileAPI.self) private var profileAPI - @Environment(AuthAPI.self) private var authAPI - @Environment(AccountContextAPI.self) private var accountContextAPI - @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var appSession: AppSession + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var permissionContext: PermissionContext + @EnvironmentObject private var appRouter: AppRouter + @Environment(\.profileAPI) private var profileAPI + @Environment(\.authAPI) private var authAPI + @Environment(\.accountContextAPI) private var accountContextAPI + @Environment(\.authSessionCoordinator) private var authSessionCoordinator + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @Environment(\.dismiss) private var dismiss - @State private var viewModel = AccountSwitchViewModel() + @StateObject private var viewModel = AccountSwitchViewModel() private var selectedAccount: AccountSwitchAccount? { viewModel.selectedAccount @@ -43,7 +43,7 @@ struct AccountSwitchView: View { @ViewBuilder private var accountList: some View { if viewModel.accounts.isEmpty && !viewModel.loading { - ContentUnavailableView( + AppContentUnavailableView( "暂无可切换账号", systemImage: "person.crop.circle.badge.exclamationmark", description: Text("当前登录账号下没有其他可切换账号。") diff --git a/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift b/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift index 7aeca51..d5fc202 100644 --- a/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift +++ b/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift @@ -10,9 +10,9 @@ import SwiftUI /// 首页菜单调试预览页,展示所有已知菜单入口并跳过权限过滤。 struct DebugHomeMenuPreviewView: View { - @Environment(AppRouter.self) private var appRouter - @Environment(RouterPath.self) private var router - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var appRouter: AppRouter + @EnvironmentObject private var router: RouterPath + @EnvironmentObject private var toastCenter: ToastCenter private let items = HomeMenuRouter.debugAllMenuItems() diff --git a/suixinkan/Features/Profile/Views/ProfileView.swift b/suixinkan/Features/Profile/Views/ProfileView.swift index b37eb96..61c5753 100644 --- a/suixinkan/Features/Profile/Views/ProfileView.swift +++ b/suixinkan/Features/Profile/Views/ProfileView.swift @@ -11,20 +11,20 @@ import UIKit /// 个人信息页视图,展示用户资料、账号状态、实名认证状态和退出登录入口。 struct ProfileView: View { - @Environment(AppSession.self) private var appSession - @Environment(AccountContext.self) private var accountContext - @Environment(PermissionContext.self) private var permissionContext - @Environment(ScenicSpotContext.self) private var scenicSpotContext - @Environment(AppRouter.self) private var appRouter - @Environment(RouterPath.self) private var router - @Environment(ProfileAPI.self) private var profileAPI - @Environment(OSSUploadService.self) private var ossUploadService - @Environment(ToastCenter.self) private var toastCenter - @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator + @EnvironmentObject private var appSession: AppSession + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var permissionContext: PermissionContext + @EnvironmentObject private var scenicSpotContext: ScenicSpotContext + @EnvironmentObject private var appRouter: AppRouter + @EnvironmentObject private var router: RouterPath + @Environment(\.profileAPI) private var profileAPI + @Environment(\.ossUploadService) private var ossUploadService + @EnvironmentObject private var toastCenter: ToastCenter + @Environment(\.authSessionCoordinator) private var authSessionCoordinator @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var viewModel = ProfileViewModel() + @StateObject private var viewModel = ProfileViewModel() @State private var showsPasswordSheet = false @State private var showsLogoutConfirm = false @State private var pickedAvatarItem: PhotosPickerItem? @@ -74,13 +74,13 @@ struct ProfileView: View { } Button("取消", role: .cancel) {} } - .onChange(of: viewModel.isEditingProfile) { _, isEditing in + .onChange(of: viewModel.isEditingProfile) { isEditing in guard isEditing else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { nicknameFocused = true } } - .onChange(of: pickedAvatarItem) { _, item in + .onChange(of: pickedAvatarItem) { item in Task { await prepareAvatarImage(from: item) } } } @@ -662,14 +662,14 @@ private struct PasswordUpdateSheet: View { #Preview { NavigationStack { ProfileView() - .environment(AppSession()) - .environment(AccountContext()) - .environment(PermissionContext()) - .environment(ScenicSpotContext()) - .environment(AppRouter()) - .environment(ToastCenter()) - .environment(ProfileAPI(client: APIClient())) - .environment(OSSUploadService(configService: UploadAPI(client: APIClient()))) - .environment(AuthSessionCoordinator()) + .environmentObject(AppSession()) + .environmentObject(AccountContext()) + .environmentObject(PermissionContext()) + .environmentObject(ScenicSpotContext()) + .environmentObject(AppRouter()) + .environmentObject(ToastCenter()) + .environment(\.profileAPI, ProfileAPI(client: APIClient())) + .environment(\.ossUploadService, OSSUploadService(configService: UploadAPI(client: APIClient()))) + .environment(\.authSessionCoordinator, AuthSessionCoordinator()) } } diff --git a/suixinkan/Features/Profile/Views/RealNameAuthView.swift b/suixinkan/Features/Profile/Views/RealNameAuthView.swift index 27baa0e..df6bee7 100644 --- a/suixinkan/Features/Profile/Views/RealNameAuthView.swift +++ b/suixinkan/Features/Profile/Views/RealNameAuthView.swift @@ -11,12 +11,12 @@ import UIKit /// 实名认证页面,展示审核状态并允许提交基础实名资料。 struct RealNameAuthView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(ProfileAPI.self) private var profileAPI - @Environment(OSSUploadService.self) private var ossUploadService - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.profileAPI) private var profileAPI + @Environment(\.ossUploadService) private var ossUploadService + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = RealNameAuthViewModel() + @StateObject private var viewModel = RealNameAuthViewModel() @State private var pickedFrontItem: PhotosPickerItem? @State private var pickedBackItem: PhotosPickerItem? @@ -38,10 +38,10 @@ struct RealNameAuthView: View { .task { await loadInfo() } - .onChange(of: pickedFrontItem) { _, item in + .onChange(of: pickedFrontItem) { item in Task { await prepareIdentityImage(from: item, side: .front) } } - .onChange(of: pickedBackItem) { _, item in + .onChange(of: pickedBackItem) { item in Task { await prepareIdentityImage(from: item, side: .back) } } } diff --git a/suixinkan/Features/Profile/Views/SettingsFlowViews.swift b/suixinkan/Features/Profile/Views/SettingsFlowViews.swift index c06450b..4b112f8 100644 --- a/suixinkan/Features/Profile/Views/SettingsFlowViews.swift +++ b/suixinkan/Features/Profile/Views/SettingsFlowViews.swift @@ -53,7 +53,7 @@ enum SettingsDisplayPolicy { /// 设置中心页面,展示关于我们、版本、下载链接和协议入口。 struct SettingsCenterView: View { - @Environment(RouterPath.self) private var router + @EnvironmentObject private var router: RouterPath @State private var copiedDownloadLink = false private var versionText: String { diff --git a/suixinkan/Features/Projects/API/ProjectAPI.swift b/suixinkan/Features/Projects/API/ProjectAPI.swift index 04adcfc..5e1a26d 100644 --- a/suixinkan/Features/Projects/API/ProjectAPI.swift +++ b/suixinkan/Features/Projects/API/ProjectAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 项目服务协议,抽象摄影师项目和店铺项目接口以便 ViewModel 测试替换。 @MainActor @@ -56,9 +56,8 @@ protocol ProjectServing { /// 项目 API,封装项目管理、店铺项目管理相关接口。 @MainActor -@Observable final class ProjectAPI: ProjectServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化项目 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift b/suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift index 68ca1d4..7794f85 100644 --- a/suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift +++ b/suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 项目表单模式,区分新建和编辑。 enum ProjectEditorMode: Equatable { @@ -48,18 +48,17 @@ enum ProjectEditorError: LocalizedError, Equatable { /// 摄影师项目管理 ViewModel,负责项目列表、分页、详情和删除。 @MainActor -@Observable -final class ProjectManagementViewModel { - private(set) var items: [PhotographerProjectItem] = [] - private(set) var loading = false - private(set) var loadingMore = false - private(set) var hasMore = false - private(set) var selectedDetail: PhotographerProjectDetailResponse? - var searchText = "" - var errorMessage: String? +final class ProjectManagementViewModel: ObservableObject { + @Published private(set) var items: [PhotographerProjectItem] = [] + @Published private(set) var loading = false + @Published private(set) var loadingMore = false + @Published private(set) var hasMore = false + @Published private(set) var selectedDetail: PhotographerProjectDetailResponse? + @Published var searchText = "" + @Published var errorMessage: String? - private var page = 1 - private var total = 0 + @Published private var page = 1 + @Published private var total = 0 private let pageSize = 10 /// 重新加载摄影师项目列表。 @@ -156,47 +155,54 @@ final class ProjectManagementViewModel { /// 项目编辑 ViewModel,负责摄影师项目表单、图片上传和提交。 @MainActor -@Observable -final class ProjectEditorViewModel { - var name = "" - var descriptionText = "" - var price = "" - var otPrice = "" - var deposit = "" - var attrLabelText = "" - var materialNum = "1" - var photoNum = "1" - var videoNum = "1" - var selectedSpotIds: Set = [] - var coverImage: ProjectLocalImage? - var carouselImages: [ProjectLocalImage] = [] - var existingCoverURL = "" - var existingCarouselURLs: [String] = [] - private(set) var submitting = false - private(set) var uploadProgress = 0 - var errorMessage: String? +final class ProjectEditorViewModel: ObservableObject { + @Published var name = "" + @Published var descriptionText = "" + @Published var price = "" + @Published var otPrice = "" + @Published var deposit = "" + @Published var attrLabelText = "" + @Published var materialNum = "1" + @Published var photoNum = "1" + @Published var videoNum = "1" + @Published var selectedSpotIds: Set = [] + @Published var coverImage: ProjectLocalImage? + @Published var carouselImages: [ProjectLocalImage] = [] + @Published var existingCoverURL = "" + @Published var existingCarouselURLs: [String] = [] + @Published private(set) var submitting = false + @Published private(set) var uploadProgress = 0 + @Published var errorMessage: String? - private let mode: ProjectEditorMode + private var mode: ProjectEditorMode /// 初始化摄影师项目表单,并在编辑模式下回填详情。 init(mode: ProjectEditorMode) { self.mode = mode if case let .edit(detail) = mode { - name = detail.name - descriptionText = detail.description - price = detail.price - otPrice = detail.otPrice - deposit = detail.priceDeposit - attrLabelText = detail.attrLabel.joined(separator: ",") - materialNum = "\(max(detail.materialNum, 0))" - photoNum = "\(max(detail.photoNum, 0))" - videoNum = "\(max(detail.videoNum, 0))" - selectedSpotIds = Set(detail.scenicList.map(\.id)) - existingCoverURL = detail.coverProject - existingCarouselURLs = detail.coverCarousel + apply(detail) } } + /// 回填编辑详情,保留同一个 ObservableObject 实例。 + func apply(_ detail: PhotographerProjectDetailResponse) { + mode = .edit(detail) + name = detail.name + descriptionText = detail.description + price = detail.price + otPrice = detail.otPrice + deposit = detail.priceDeposit + attrLabelText = detail.attrLabel.joined(separator: ",") + materialNum = "\(max(detail.materialNum, 0))" + photoNum = "\(max(detail.photoNum, 0))" + videoNum = "\(max(detail.videoNum, 0))" + selectedSpotIds = Set(detail.scenicList.map(\.id)) + existingCoverURL = detail.coverProject + existingCarouselURLs = detail.coverCarousel + coverImage = nil + carouselImages = [] + } + /// 提交摄影师项目,必要时先上传封面和轮播图。 func submit( scenicId: Int?, @@ -304,14 +310,13 @@ final class ProjectEditorViewModel { /// 店铺项目管理 ViewModel,负责店铺项目列表、筛选、详情和删除。 @MainActor -@Observable -final class StoreProjectManagementViewModel { - private(set) var items: [PhotographerProjectItem] = [] - private(set) var loading = false - private(set) var selectedDetail: PhotographerProjectDetailResponse? - var searchText = "" - var selectedType = 0 - var errorMessage: String? +final class StoreProjectManagementViewModel: ObservableObject { + @Published private(set) var items: [PhotographerProjectItem] = [] + @Published private(set) var loading = false + @Published private(set) var selectedDetail: PhotographerProjectDetailResponse? + @Published var searchText = "" + @Published var selectedType = 0 + @Published var errorMessage: String? /// 根据本地筛选条件返回展示项目。 var filteredItems: [PhotographerProjectItem] { @@ -364,51 +369,58 @@ final class StoreProjectManagementViewModel { /// 店铺项目编辑 ViewModel,负责多点位和押金项目表单提交。 @MainActor -@Observable -final class StoreProjectEditorViewModel { - var projectType = 19 - var scenicList: [StoreManagerScenicItem] = [] - var storeList: [StoreItem] = [] - var selectedScenicIds: Set = [] - var selectedStoreId: Int? - var name = "" - var descriptionText = "" - var coverImage: ProjectLocalImage? - var carouselImages: [ProjectLocalImage] = [] - var existingCoverURL = "" - var existingCarouselURLs: [String] = [] - var settleSpotNum = "1" - var projectPrice = "" - var priceMaterial = "" - var pricePhoto = "" - var priceVideo = "" - var priceDeposit = "" - var materialNum = "1" - var photoNum = "1" - var videoNum = "1" - private(set) var submitting = false - var errorMessage: String? +final class StoreProjectEditorViewModel: ObservableObject { + @Published var projectType = 19 + @Published var scenicList: [StoreManagerScenicItem] = [] + @Published var storeList: [StoreItem] = [] + @Published var selectedScenicIds: Set = [] + @Published var selectedStoreId: Int? + @Published var name = "" + @Published var descriptionText = "" + @Published var coverImage: ProjectLocalImage? + @Published var carouselImages: [ProjectLocalImage] = [] + @Published var existingCoverURL = "" + @Published var existingCarouselURLs: [String] = [] + @Published var settleSpotNum = "1" + @Published var projectPrice = "" + @Published var priceMaterial = "" + @Published var pricePhoto = "" + @Published var priceVideo = "" + @Published var priceDeposit = "" + @Published var materialNum = "1" + @Published var photoNum = "1" + @Published var videoNum = "1" + @Published private(set) var submitting = false + @Published var errorMessage: String? - private let mode: StoreProjectEditorMode + private var mode: StoreProjectEditorMode /// 初始化店铺项目表单,并在编辑模式下回填详情。 init(mode: StoreProjectEditorMode) { self.mode = mode if case let .edit(detail) = mode { - projectType = detail.type == 4 ? 4 : 19 - name = detail.name - descriptionText = detail.description - existingCoverURL = detail.coverProject - existingCarouselURLs = detail.coverCarousel - selectedScenicIds = Set(detail.scenicList.map(\.id)) - projectPrice = detail.price - priceDeposit = detail.priceDeposit - materialNum = "\(max(detail.materialNum, 0))" - photoNum = "\(max(detail.photoNum, 0))" - videoNum = "\(max(detail.videoNum, 0))" + apply(detail) } } + /// 回填编辑详情,保留同一个 ObservableObject 实例。 + func apply(_ detail: PhotographerProjectDetailResponse) { + mode = .edit(detail) + projectType = detail.type == 4 ? 4 : 19 + name = detail.name + descriptionText = detail.description + existingCoverURL = detail.coverProject + existingCarouselURLs = detail.coverCarousel + selectedScenicIds = Set(detail.scenicList.map(\.id)) + projectPrice = detail.price + priceDeposit = detail.priceDeposit + materialNum = "\(max(detail.materialNum, 0))" + photoNum = "\(max(detail.photoNum, 0))" + videoNum = "\(max(detail.videoNum, 0))" + coverImage = nil + carouselImages = [] + } + /// 加载店铺项目可管理景区。 func loadScenicList(api: any ProjectServing, userId: Int) async { guard scenicList.isEmpty, userId > 0 else { return } diff --git a/suixinkan/Features/Projects/Views/ProjectViews.swift b/suixinkan/Features/Projects/Views/ProjectViews.swift index 6997cf4..6a6dc59 100644 --- a/suixinkan/Features/Projects/Views/ProjectViews.swift +++ b/suixinkan/Features/Projects/Views/ProjectViews.swift @@ -11,17 +11,17 @@ import UniformTypeIdentifiers /// 摄影师项目管理页面,展示项目列表并提供新建、详情和删除入口。 struct ProjectManagementView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(AccountSnapshotStore.self) private var snapshotStore - @Environment(ProjectAPI.self) private var projectAPI + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.accountSnapshotStore) private var snapshotStore + @Environment(\.projectAPI) private var projectAPI @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = ProjectManagementViewModel() + @StateObject private var viewModel = ProjectManagementViewModel() var body: some View { VStack(spacing: 0) { filterBar if accountContext.currentScenic?.id == nil { - ContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。")) + AppContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。")) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { projectList @@ -66,7 +66,7 @@ struct ProjectManagementView: View { ScrollView { LazyVStack(spacing: AppMetrics.Spacing.medium) { if viewModel.items.isEmpty && !viewModel.loading { - ContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。")) + AppContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。")) .frame(maxWidth: .infinity, minHeight: 260) } ForEach(viewModel.items) { item in @@ -110,10 +110,10 @@ struct ProjectManagementView: View { /// 店铺项目管理页面,展示店铺项目列表、筛选和新建入口。 struct StoreProjectManagementView: View { - @Environment(AccountSnapshotStore.self) private var snapshotStore - @Environment(ProjectAPI.self) private var projectAPI + @Environment(\.accountSnapshotStore) private var snapshotStore + @Environment(\.projectAPI) private var projectAPI @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = StoreProjectManagementViewModel() + @StateObject private var viewModel = StoreProjectManagementViewModel() var body: some View { ScrollView { @@ -127,7 +127,7 @@ struct StoreProjectManagementView: View { .buttonStyle(.plain) } if viewModel.filteredItems.isEmpty && !viewModel.loading { - ContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。")) + AppContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。")) .frame(minHeight: 260) } } @@ -196,7 +196,7 @@ struct StoreProjectManagementView: View { /// 项目详情页面,展示项目基础信息、媒体、打卡点和摄影师摘要。 struct ProjectDetailView: View { - @Environment(ProjectAPI.self) private var projectAPI + @Environment(\.projectAPI) private var projectAPI @Environment(\.globalLoading) private var globalLoading let projectId: Int let storeMode: Bool @@ -209,7 +209,7 @@ struct ProjectDetailView: View { if let detail { ProjectDetailContentView(detail: detail) } else if let errorMessage { - ContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage)) + AppContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage)) .frame(minHeight: 360) } else { ProgressView() @@ -247,14 +247,14 @@ struct ProjectDetailView: View { /// 摄影师项目编辑页面,支持新建和编辑项目。 struct ProjectEditorView: View { @Environment(\.dismiss) private var dismiss - @Environment(AccountContext.self) private var accountContext - @Environment(AccountSnapshotStore.self) private var snapshotStore - @Environment(ScenicSpotContext.self) private var scenicSpotContext - @Environment(ProjectAPI.self) private var projectAPI - @Environment(OSSUploadService.self) private var uploadService + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.accountSnapshotStore) private var snapshotStore + @EnvironmentObject private var scenicSpotContext: ScenicSpotContext + @Environment(\.projectAPI) private var projectAPI + @Environment(\.ossUploadService) private var uploadService @Environment(\.globalLoading) private var globalLoading let projectId: Int? - @State private var viewModel = ProjectEditorViewModel(mode: .create) + @StateObject private var viewModel = ProjectEditorViewModel(mode: .create) @State private var coverItem: PhotosPickerItem? @State private var carouselItems: [PhotosPickerItem] = [] @State private var loadingDetail = false @@ -291,10 +291,10 @@ struct ProjectEditorView: View { .task { await prepare() } - .onChange(of: coverItem) { _, newValue in + .onChange(of: coverItem) { newValue in Task { await loadCover(newValue) } } - .onChange(of: carouselItems) { _, newValue in + .onChange(of: carouselItems) { newValue in Task { await loadCarousel(newValue) } } } @@ -387,7 +387,7 @@ struct ProjectEditorView: View { defer { loadingDetail = false } do { let detail = try await projectAPI.projectDetail(id: projectId) - viewModel = ProjectEditorViewModel(mode: .edit(detail)) + viewModel.apply(detail) } catch { viewModel.errorMessage = error.localizedDescription } @@ -418,12 +418,12 @@ struct ProjectEditorView: View { /// 店铺项目编辑页面,支持新建和编辑店铺项目。 struct StoreProjectEditorView: View { @Environment(\.dismiss) private var dismiss - @Environment(AccountSnapshotStore.self) private var snapshotStore - @Environment(ProjectAPI.self) private var projectAPI - @Environment(OSSUploadService.self) private var uploadService + @Environment(\.accountSnapshotStore) private var snapshotStore + @Environment(\.projectAPI) private var projectAPI + @Environment(\.ossUploadService) private var uploadService @Environment(\.globalLoading) private var globalLoading let projectId: Int? - @State private var viewModel = StoreProjectEditorViewModel(mode: .create) + @StateObject private var viewModel = StoreProjectEditorViewModel(mode: .create) @State private var coverItem: PhotosPickerItem? @State private var carouselItems: [PhotosPickerItem] = [] @@ -454,10 +454,10 @@ struct StoreProjectEditorView: View { } } .task { await prepare() } - .onChange(of: coverItem) { _, newValue in + .onChange(of: coverItem) { newValue in Task { viewModel.coverImage = await ProjectPickerLoader.loadImage(from: newValue) } } - .onChange(of: carouselItems) { _, newValue in + .onChange(of: carouselItems) { newValue in Task { viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: newValue) } } } @@ -545,7 +545,7 @@ struct StoreProjectEditorView: View { if let projectId { do { let detail = try await projectAPI.storeManagerProjectDetail(id: projectId) - viewModel = StoreProjectEditorViewModel(mode: .edit(detail)) + viewModel.apply(detail) } catch { viewModel.errorMessage = error.localizedDescription } diff --git a/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift b/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift index 924f63d..df20778 100644 --- a/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift +++ b/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 打卡点服务协议,抽象列表、详情和增删改接口以便单元测试替换。 @MainActor @@ -29,9 +29,8 @@ protocol PunchPointServing { /// 打卡点 API,负责封装旧工程打卡点管理相关接口。 @MainActor -@Observable final class PunchPointAPI: PunchPointServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化打卡点 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift index cdb6949..303e845 100644 --- a/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift +++ b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift @@ -6,21 +6,20 @@ // import Foundation -import Observation +import Combine /// 打卡点列表 ViewModel,负责筛选、分页、详情加载和删除刷新。 @MainActor -@Observable -final class PunchPointListViewModel { - var selectedFilter: PunchPointFilter = .all - var items: [PunchPointItem] = [] - var selectedDetail: PunchPointItem? - var errorMessage: String? - var isLoading = false - var isLoadingMore = false - var total = 0 +final class PunchPointListViewModel: ObservableObject { + @Published var selectedFilter: PunchPointFilter = .all + @Published var items: [PunchPointItem] = [] + @Published var selectedDetail: PunchPointItem? + @Published var errorMessage: String? + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var total = 0 - private var page = 1 + @Published private var page = 1 private let pageSize = 20 /// 是否还存在下一页数据。 @@ -121,35 +120,41 @@ final class PunchPointListViewModel { /// 打卡点编辑 ViewModel,负责新增、编辑、表单校验和 OSS 图片上传。 @MainActor -@Observable -final class PunchPointEditorViewModel { - var name = "" - var description = "" - var address = "" - var latitudeText = "" - var longitudeText = "" - var scenicSpotText = "" - var remoteImages: [String] = [] - var localImages: [PunchPointLocalImage] = [] - var errorMessage: String? - var isSubmitting = false +final class PunchPointEditorViewModel: ObservableObject { + @Published var name = "" + @Published var description = "" + @Published var address = "" + @Published var latitudeText = "" + @Published var longitudeText = "" + @Published var scenicSpotText = "" + @Published var remoteImages: [String] = [] + @Published var localImages: [PunchPointLocalImage] = [] + @Published var errorMessage: String? + @Published var isSubmitting = false - let editingItem: PunchPointItem? + private(set) var editingItem: PunchPointItem? /// 初始化编辑 ViewModel,可传入已有打卡点作为编辑表单初值。 init(item: PunchPointItem? = nil) { editingItem = item if let item { - name = item.name - description = item.description - address = item.region?.address ?? "" - latitudeText = item.region.map { String($0.lat) } ?? "" - longitudeText = item.region.map { String($0.lot) } ?? "" - scenicSpotText = item.scenicSpotStr - remoteImages = item.guideImages + apply(item) } } + /// 回填编辑详情,保留同一个 ObservableObject 实例。 + func apply(_ item: PunchPointItem) { + editingItem = item + name = item.name + description = item.description + address = item.region?.address ?? "" + latitudeText = item.region.map { String($0.lat) } ?? "" + longitudeText = item.region.map { String($0.lot) } ?? "" + scenicSpotText = item.scenicSpotStr + remoteImages = item.guideImages + localImages = [] + } + /// 设置经纬度和地址,通常由定位或地图选点回填。 func applyLocation(latitude: Double, longitude: Double, address: String) { latitudeText = String(format: "%.6f", latitude) diff --git a/suixinkan/Features/PunchPoint/Views/PunchPointViews.swift b/suixinkan/Features/PunchPoint/Views/PunchPointViews.swift index 5ebbdd9..d3b2bfa 100644 --- a/suixinkan/Features/PunchPoint/Views/PunchPointViews.swift +++ b/suixinkan/Features/PunchPoint/Views/PunchPointViews.swift @@ -11,15 +11,15 @@ import UIKit /// 打卡点列表页面,展示当前景区下的打卡点并提供新增入口。 struct PunchPointListView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(AccountContextAPI.self) private var accountContextAPI - @Environment(PunchPointAPI.self) private var punchPointAPI - @Environment(RouterPath.self) private var router - @Environment(ScenicSpotContext.self) private var scenicSpotContext - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.accountContextAPI) private var accountContextAPI + @Environment(\.punchPointAPI) private var punchPointAPI + @EnvironmentObject private var router: RouterPath + @EnvironmentObject private var scenicSpotContext: ScenicSpotContext + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = PunchPointListViewModel() + @StateObject private var viewModel = PunchPointListViewModel() var body: some View { ScrollView { @@ -58,7 +58,7 @@ struct PunchPointListView: View { } } .pickerStyle(.segmented) - .onChange(of: viewModel.selectedFilter) { _, newValue in + .onChange(of: viewModel.selectedFilter) { newValue in Task { await globalLoading.withOptionalLoading(currentScenicId != nil) { await viewModel.selectFilter(newValue, scenicId: currentScenicId, api: punchPointAPI) @@ -116,15 +116,15 @@ struct PunchPointDetailView: View { let punchPointId: Int let summary: PunchPointItem? - @Environment(AccountContext.self) private var accountContext - @Environment(AccountContextAPI.self) private var accountContextAPI - @Environment(PunchPointAPI.self) private var punchPointAPI - @Environment(RouterPath.self) private var router - @Environment(ScenicSpotContext.self) private var scenicSpotContext - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.accountContextAPI) private var accountContextAPI + @Environment(\.punchPointAPI) private var punchPointAPI + @EnvironmentObject private var router: RouterPath + @EnvironmentObject private var scenicSpotContext: ScenicSpotContext + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = PunchPointListViewModel() + @StateObject private var viewModel = PunchPointListViewModel() @State private var showDeleteConfirm = false var item: PunchPointItem? { @@ -275,17 +275,17 @@ struct PunchPointDetailView: View { struct PunchPointEditorView: View { let punchPointId: Int? - @Environment(AccountContext.self) private var accountContext - @Environment(AccountContextAPI.self) private var accountContextAPI - @Environment(PunchPointAPI.self) private var punchPointAPI - @Environment(OSSUploadService.self) private var uploadService - @Environment(ScenicSpotContext.self) private var scenicSpotContext - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.accountContextAPI) private var accountContextAPI + @Environment(\.punchPointAPI) private var punchPointAPI + @Environment(\.ossUploadService) private var uploadService + @EnvironmentObject private var scenicSpotContext: ScenicSpotContext + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading - @State private var detailLoader = PunchPointListViewModel() - @State private var viewModel = PunchPointEditorViewModel() + @StateObject private var detailLoader = PunchPointListViewModel() + @StateObject private var viewModel = PunchPointEditorViewModel() @State private var locationProvider = ForegroundLocationProvider() @State private var selectedItems: [PhotosPickerItem] = [] @@ -308,11 +308,11 @@ struct PunchPointEditorView: View { await globalLoading.withOptionalLoading(detailLoader.selectedDetail == nil) { await detailLoader.loadDetail(id: punchPointId, api: punchPointAPI) if let detail = detailLoader.selectedDetail { - viewModel = PunchPointEditorViewModel(item: detail) + viewModel.apply(detail) } } } - .onChange(of: selectedItems) { _, items in + .onChange(of: selectedItems) { items in Task { await loadPickedImages(items) } } } diff --git a/suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift b/suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift index 995751f..cd4c347 100644 --- a/suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift +++ b/suixinkan/Features/QueueManagement/API/ScenicQueueAPI.swift @@ -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) { diff --git a/suixinkan/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift b/suixinkan/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift index 78155d0..788f4e0 100644 --- a/suixinkan/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift +++ b/suixinkan/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift @@ -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() - @ObservationIgnored private var handledRemoteCallEventIds = Set() + 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() + @Published private var handledRemoteCallEventIds = Set() /// 待处理排队数量。 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 { diff --git a/suixinkan/Features/QueueManagement/Views/QueueManagementViews.swift b/suixinkan/Features/QueueManagement/Views/QueueManagementViews.swift index 52aa7bb..7072326 100644 --- a/suixinkan/Features/QueueManagement/Views/QueueManagementViews.swift +++ b/suixinkan/Features/QueueManagement/Views/QueueManagementViews.swift @@ -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 { diff --git a/suixinkan/Features/QueueManagement/Views/ScenicQueueSettingsViews.swift b/suixinkan/Features/QueueManagement/Views/ScenicQueueSettingsViews.swift index e8457ff..576bb73 100644 --- a/suixinkan/Features/QueueManagement/Views/ScenicQueueSettingsViews.swift +++ b/suixinkan/Features/QueueManagement/Views/ScenicQueueSettingsViews.swift @@ -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 diff --git a/suixinkan/Features/ScenicPermission/API/ScenicPermissionAPI.swift b/suixinkan/Features/ScenicPermission/API/ScenicPermissionAPI.swift index 5679043..9fc1b9d 100644 --- a/suixinkan/Features/ScenicPermission/API/ScenicPermissionAPI.swift +++ b/suixinkan/Features/ScenicPermission/API/ScenicPermissionAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 景区权限模块服务协议,定义景区选择、权限申请和景区申请所需接口。 @MainActor @@ -34,10 +34,9 @@ protocol ScenicPermissionServing { } @MainActor -@Observable /// 景区权限 API,封装景区选择和申请相关网络请求。 final class ScenicPermissionAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化景区权限 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/ScenicPermission/ViewModels/PermissionApplyViewModel.swift b/suixinkan/Features/ScenicPermission/ViewModels/PermissionApplyViewModel.swift index 77818cb..39b319d 100644 --- a/suixinkan/Features/ScenicPermission/ViewModels/PermissionApplyViewModel.swift +++ b/suixinkan/Features/ScenicPermission/ViewModels/PermissionApplyViewModel.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 权限申请角色选项实体,表示可申请的一个业务角色。 struct PermissionRoleOption: Equatable, Identifiable { @@ -31,21 +31,20 @@ struct ExistingRolePermissionInfo: Equatable, Identifiable { } @MainActor -@Observable /// 权限申请 ViewModel,负责角色/景区选择和提交审核。 -final class PermissionApplyViewModel { - var roleOptions: [PermissionRoleOption] = [] - var selectedRoleId: Int? - var scenicOptions: [PermissionScenicOption] = [] - var loadingScenics = false - var scenicLoadFailed = false - var scenicLoadFailureReason: String? - var submitting = false - var message: String? +final class PermissionApplyViewModel: ObservableObject { + @Published var roleOptions: [PermissionRoleOption] = [] + @Published var selectedRoleId: Int? + @Published var scenicOptions: [PermissionScenicOption] = [] + @Published var loadingScenics = false + @Published var scenicLoadFailed = false + @Published var scenicLoadFailureReason: String? + @Published var submitting = false + @Published var message: String? private let initialPending: RoleApplyPendingResponse? - private var rolePermissions: [RolePermissionResponse] = [] - private var selectedIds: Set = [] + @Published private var rolePermissions: [RolePermissionResponse] = [] + @Published private var selectedIds: Set = [] /// 初始化权限申请 ViewModel,可传入驳回申请用于编辑回填。 init(initialPending: RoleApplyPendingResponse? = nil) { @@ -181,13 +180,12 @@ final class PermissionApplyViewModel { } @MainActor -@Observable /// 权限申请状态 ViewModel,负责读取审核中的或指定编号的权限申请。 -final class PermissionApplyStatusViewModel { - var loading = false - var pending: RoleApplyPendingResponse? - var loadFailed = false - var loadFailureReason: String? +final class PermissionApplyStatusViewModel: ObservableObject { + @Published var loading = false + @Published var pending: RoleApplyPendingResponse? + @Published var loadFailed = false + @Published var loadFailureReason: String? /// 加载权限申请状态;有申请编号时优先匹配编号,否则展示审核中或驳回记录。 func load(api: any ScenicPermissionServing, applyCode: String?) async { diff --git a/suixinkan/Features/ScenicPermission/ViewModels/ScenicApplicationViewModel.swift b/suixinkan/Features/ScenicPermission/ViewModels/ScenicApplicationViewModel.swift index 773bc2c..3bdc411 100644 --- a/suixinkan/Features/ScenicPermission/ViewModels/ScenicApplicationViewModel.swift +++ b/suixinkan/Features/ScenicPermission/ViewModels/ScenicApplicationViewModel.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 景区申请本地图片草稿实体,表示尚未或正在上传的图片。 struct ScenicApplicationImageDraft: Equatable, Identifiable { @@ -29,27 +29,26 @@ struct ScenicApplicationImageDraft: Equatable, Identifiable { } @MainActor -@Observable /// 景区申请 ViewModel,负责新增景区申请表单、图片上传和待审核记录回填。 -final class ScenicApplicationViewModel { - var scenicName = "" - var imageURLs = "" - var localImages: [ScenicApplicationImageDraft] = [] - var selectedProvince = "" - var selectedCity = "" - var coopType = 1 - var remark = "" - var agreed = false - var submitting = false - var message: String? - var provinces: [ScenicAreaNode] = [] - var cities: [ScenicAreaNode] = [] - var pending: ScenicApplicationPendingResponse? - var loading = false - var loadFailed = false - var loadFailureReason: String? - var pendingLoadFailed = false - var pendingLoadFailureReason: String? +final class ScenicApplicationViewModel: ObservableObject { + @Published var scenicName = "" + @Published var imageURLs = "" + @Published var localImages: [ScenicApplicationImageDraft] = [] + @Published var selectedProvince = "" + @Published var selectedCity = "" + @Published var coopType = 1 + @Published var remark = "" + @Published var agreed = false + @Published var submitting = false + @Published var message: String? + @Published var provinces: [ScenicAreaNode] = [] + @Published var cities: [ScenicAreaNode] = [] + @Published var pending: ScenicApplicationPendingResponse? + @Published var loading = false + @Published var loadFailed = false + @Published var loadFailureReason: String? + @Published var pendingLoadFailed = false + @Published var pendingLoadFailureReason: String? /// 返回当前申请是否只读;审核中的申请不可再次编辑提交。 var isReadOnly: Bool { diff --git a/suixinkan/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift b/suixinkan/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift index 349a53e..1e7a247 100644 --- a/suixinkan/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift +++ b/suixinkan/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift @@ -7,7 +7,7 @@ import CoreLocation import Foundation -import Observation +import Combine /// 景区选择展示实体,表示列表中的一个可切换景区。 struct ScenicSelectionItem: Equatable, Identifiable { @@ -51,13 +51,12 @@ struct ScenicSelectionItem: Equatable, Identifiable { } @MainActor -@Observable /// 景区选择 ViewModel,负责搜索、定位距离计算和切换景区持久化。 -final class ScenicSelectionViewModel { - var searchQuery = "" - var currentLocationText = "定位后获取最近景区" - var locationWarning: String? - private(set) var items: [ScenicSelectionItem] = [] +final class ScenicSelectionViewModel: ObservableObject { + @Published var searchQuery = "" + @Published var currentLocationText = "定位后获取最近景区" + @Published var locationWarning: String? + @Published private(set) var items: [ScenicSelectionItem] = [] /// 返回按搜索关键词过滤后的景区列表。 var filteredItems: [ScenicSelectionItem] { diff --git a/suixinkan/Features/ScenicPermission/Views/PermissionApplyStatusView.swift b/suixinkan/Features/ScenicPermission/Views/PermissionApplyStatusView.swift index 2b8838a..e374987 100644 --- a/suixinkan/Features/ScenicPermission/Views/PermissionApplyStatusView.swift +++ b/suixinkan/Features/ScenicPermission/Views/PermissionApplyStatusView.swift @@ -9,8 +9,8 @@ import SwiftUI /// 权限申请状态页面,展示审核中、通过或驳回的角色景区权限申请。 struct PermissionApplyStatusView: View { - @Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI - @State private var viewModel = PermissionApplyStatusViewModel() + @Environment(\.scenicPermissionAPI) private var scenicPermissionAPI + @StateObject private var viewModel = PermissionApplyStatusViewModel() @State private var editingPending: RoleApplyPendingResponse? let applyCode: String? @@ -27,7 +27,7 @@ struct PermissionApplyStatusView: View { roleCard(pending) scenicCard(pending) } else if viewModel.loadFailed { - ContentUnavailableView( + AppContentUnavailableView( "申请状态加载失败", systemImage: "exclamationmark.triangle", description: Text(viewModel.loadFailureReason ?? "请检查网络后重试") @@ -38,7 +38,7 @@ struct PermissionApplyStatusView: View { .font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold)) .buttonStyle(.plain) } else { - ContentUnavailableView("暂无审核中的申请", systemImage: "doc.text.magnifyingglass") + AppContentUnavailableView("暂无审核中的申请", systemImage: "doc.text.magnifyingglass") } } .padding(AppMetrics.Spacing.medium) @@ -57,7 +57,7 @@ struct PermissionApplyStatusView: View { } } } - .navigationDestination(item: $editingPending) { pending in + .appNavigationDestination(item: $editingPending) { pending in PermissionApplyView(initialPending: pending) } .task { diff --git a/suixinkan/Features/ScenicPermission/Views/PermissionApplyView.swift b/suixinkan/Features/ScenicPermission/Views/PermissionApplyView.swift index 96e3542..8478f2b 100644 --- a/suixinkan/Features/ScenicPermission/Views/PermissionApplyView.swift +++ b/suixinkan/Features/ScenicPermission/Views/PermissionApplyView.swift @@ -9,14 +9,14 @@ import SwiftUI /// 权限申请页面,支持申请某角色在更多景区下的权限。 struct PermissionApplyView: View { - @Environment(PermissionContext.self) private var permissionContext - @Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI - @State private var viewModel: PermissionApplyViewModel + @EnvironmentObject private var permissionContext: PermissionContext + @Environment(\.scenicPermissionAPI) private var scenicPermissionAPI + @StateObject private var viewModel: PermissionApplyViewModel @State private var activePicker: PermissionPickerSheet? /// 初始化权限申请页面,可传入驳回记录用于编辑。 init(initialPending: RoleApplyPendingResponse? = nil) { - _viewModel = State(initialValue: PermissionApplyViewModel(initialPending: initialPending)) + _viewModel = StateObject(wrappedValue: PermissionApplyViewModel(initialPending: initialPending)) } var body: some View { diff --git a/suixinkan/Features/ScenicPermission/Views/ScenicApplicationView.swift b/suixinkan/Features/ScenicPermission/Views/ScenicApplicationView.swift index 2177184..c23c039 100644 --- a/suixinkan/Features/ScenicPermission/Views/ScenicApplicationView.swift +++ b/suixinkan/Features/ScenicPermission/Views/ScenicApplicationView.swift @@ -10,9 +10,9 @@ import SwiftUI /// 景区申请页面,支持新增景区入驻资料、图片上传和审核状态回填。 struct ScenicApplicationView: View { - @Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI - @Environment(OSSUploadService.self) private var ossUploadService - @State private var viewModel = ScenicApplicationViewModel() + @Environment(\.scenicPermissionAPI) private var scenicPermissionAPI + @Environment(\.ossUploadService) private var ossUploadService + @StateObject private var viewModel = ScenicApplicationViewModel() @State private var pickedImageItems: [PhotosPickerItem] = [] @State private var activeLocationPicker: ScenicLocationPicker? @@ -21,7 +21,7 @@ struct ScenicApplicationView: View { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { if viewModel.loadFailed { - ContentUnavailableView( + AppContentUnavailableView( "景区申请初始化失败", systemImage: "exclamationmark.triangle", description: Text(viewModel.loadFailureReason ?? "请检查网络后重试") @@ -48,7 +48,7 @@ struct ScenicApplicationView: View { .task { await viewModel.loadInitial(api: scenicPermissionAPI) } - .onChange(of: pickedImageItems) { _, items in + .onChange(of: pickedImageItems) { items in guard !items.isEmpty else { return } Task { await importPickedImages(items) @@ -111,7 +111,7 @@ struct ScenicApplicationView: View { locationSection coopTypeSection inputField("备注信息 (\(viewModel.remark.count)/50)", text: $viewModel.remark, axis: .vertical, disabled: viewModel.isReadOnly) - .onChange(of: viewModel.remark) { _, value in + .onChange(of: viewModel.remark) { value in if value.count > 50 { viewModel.remark = String(value.prefix(50)) } diff --git a/suixinkan/Features/ScenicPermission/Views/ScenicSelectionView.swift b/suixinkan/Features/ScenicPermission/Views/ScenicSelectionView.swift index 2edb07d..ffd45ab 100644 --- a/suixinkan/Features/ScenicPermission/Views/ScenicSelectionView.swift +++ b/suixinkan/Features/ScenicPermission/Views/ScenicSelectionView.swift @@ -10,12 +10,12 @@ import SwiftUI /// 景区选择页面,支持搜索、定位距离展示和切换当前景区。 struct ScenicSelectionView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(PermissionContext.self) private var permissionContext - @Environment(AccountSnapshotStore.self) private var snapshotStore + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var permissionContext: PermissionContext + @Environment(\.accountSnapshotStore) private var snapshotStore @Environment(\.dismiss) private var dismiss - @State private var viewModel = ScenicSelectionViewModel() + @StateObject private var viewModel = ScenicSelectionViewModel() @State private var locationProvider = ScenicSelectionLocationProvider() var body: some View { @@ -109,7 +109,7 @@ struct ScenicSelectionView: View { private var content: some View { ScrollView { if viewModel.filteredItems.isEmpty { - ContentUnavailableView( + AppContentUnavailableView( "暂无可选景区", systemImage: "mountain.2", description: Text("可尝试清空搜索关键词或刷新定位。") diff --git a/suixinkan/Features/ScenicSettlement/API/ScenicSettlementAPI.swift b/suixinkan/Features/ScenicSettlement/API/ScenicSettlementAPI.swift index 319ccaf..fcb62ea 100644 --- a/suixinkan/Features/ScenicSettlement/API/ScenicSettlementAPI.swift +++ b/suixinkan/Features/ScenicSettlement/API/ScenicSettlementAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 景区结算服务协议,定义结算申请提交能力。 @MainActor @@ -16,10 +16,9 @@ protocol ScenicSettlementServing { } @MainActor -@Observable /// 景区结算 API,封装结算申请提交网络请求。 final class ScenicSettlementAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化景区结算 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift b/suixinkan/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift index 0551d48..7da143a 100644 --- a/suixinkan/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift +++ b/suixinkan/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift @@ -6,24 +6,23 @@ // import Foundation -import Observation +import Combine import SwiftUI @MainActor -@Observable /// 景区结算申请 ViewModel,管理可申请景区、多选、金额备注和提交。 -final class ScenicSettlementViewModel { - var existingScenics: [BusinessScope] = [] - var options: [ScenicSettlementOption] = [] - var amountText = "" - var remarkText = "" - var isLoading = false - var isSubmitting = false - var loadFailed = false - var loadFailureReason: String? - var message: String? +final class ScenicSettlementViewModel: ObservableObject { + @Published var existingScenics: [BusinessScope] = [] + @Published var options: [ScenicSettlementOption] = [] + @Published var amountText = "" + @Published var remarkText = "" + @Published var isLoading = false + @Published var isSubmitting = false + @Published var loadFailed = false + @Published var loadFailureReason: String? + @Published var message: String? - private var selectedIds = Set() + @Published private var selectedIds = Set() /// 已选择景区数量。 var selectedCount: Int { @@ -176,16 +175,15 @@ enum ScenicSettlementAmountValidation: Equatable { } @MainActor -@Observable /// 景区结算审核 ViewModel,聚合景区申请和权限申请审核记录。 -final class ScenicSettlementReviewViewModel { - var scenicApplications: [ScenicApplicationPendingResponse] = [] - var roleApplications: [RoleApplyPendingResponse] = [] - var isLoading = false - var message: String? - var loadFailedAll = false - var scenicLoadFailed = false - var roleLoadFailed = false +final class ScenicSettlementReviewViewModel: ObservableObject { + @Published var scenicApplications: [ScenicApplicationPendingResponse] = [] + @Published var roleApplications: [RoleApplyPendingResponse] = [] + @Published var isLoading = false + @Published var message: String? + @Published var loadFailedAll = false + @Published var scenicLoadFailed = false + @Published var roleLoadFailed = false /// 待审核记录数量。 var pendingCount: Int { diff --git a/suixinkan/Features/ScenicSettlement/Views/ScenicSettlementViews.swift b/suixinkan/Features/ScenicSettlement/Views/ScenicSettlementViews.swift index 3007ad4..d123083 100644 --- a/suixinkan/Features/ScenicSettlement/Views/ScenicSettlementViews.swift +++ b/suixinkan/Features/ScenicSettlement/Views/ScenicSettlementViews.swift @@ -9,11 +9,11 @@ import SwiftUI /// 景区结算申请页,支持选择未开通结算的景区并提交金额。 struct ScenicSettlementView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI - @Environment(ScenicSettlementAPI.self) private var scenicSettlementAPI - @Environment(ToastCenter.self) private var toastCenter - @State private var viewModel = ScenicSettlementViewModel() + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.scenicPermissionAPI) private var scenicPermissionAPI + @Environment(\.scenicSettlementAPI) private var scenicSettlementAPI + @EnvironmentObject private var toastCenter: ToastCenter + @StateObject private var viewModel = ScenicSettlementViewModel() @State private var showScenicApplication = false var body: some View { @@ -45,7 +45,7 @@ struct ScenicSettlementView: View { ScenicApplicationView() } } - .onChange(of: viewModel.message) { _, message in + .onChange(of: viewModel.message) { message in guard let message else { return } toastCenter.show(message) viewModel.message = nil @@ -92,7 +92,7 @@ struct ScenicSettlementView: View { .frame(maxWidth: .infinity, minHeight: 120) } else if viewModel.loadFailed { VStack(spacing: AppMetrics.Spacing.xSmall) { - ContentUnavailableView( + AppContentUnavailableView( "可申请景区加载失败", systemImage: "exclamationmark.triangle", description: Text(viewModel.loadFailureReason ?? "请检查网络后重试") @@ -195,9 +195,9 @@ struct ScenicSettlementView: View { /// 景区结算审核记录页,展示景区申请和权限申请审核状态。 struct ScenicSettlementReviewView: View { - @Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI - @Environment(ToastCenter.self) private var toastCenter - @State private var viewModel = ScenicSettlementReviewViewModel() + @Environment(\.scenicPermissionAPI) private var scenicPermissionAPI + @EnvironmentObject private var toastCenter: ToastCenter + @StateObject private var viewModel = ScenicSettlementReviewViewModel() var body: some View { ScrollView { @@ -221,7 +221,7 @@ struct ScenicSettlementReviewView: View { .refreshable { await viewModel.load(api: scenicPermissionAPI) } - .onChange(of: viewModel.message) { _, message in + .onChange(of: viewModel.message) { message in guard let message else { return } toastCenter.show(message) viewModel.message = nil @@ -230,7 +230,7 @@ struct ScenicSettlementReviewView: View { private var fullFailureView: some View { VStack(spacing: AppMetrics.Spacing.small) { - ContentUnavailableView( + AppContentUnavailableView( "审核记录加载失败", systemImage: "exclamationmark.triangle", description: Text("景区申请与权限申请均未加载成功,请检查网络后重试") diff --git a/suixinkan/Features/Schedule/API/ScheduleAPI.swift b/suixinkan/Features/Schedule/API/ScheduleAPI.swift index aad6256..d2b7cfe 100644 --- a/suixinkan/Features/Schedule/API/ScheduleAPI.swift +++ b/suixinkan/Features/Schedule/API/ScheduleAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 排班服务协议,抽象排班列表、新增、删除和可关联订单接口。 @MainActor @@ -29,9 +29,8 @@ protocol ScheduleServing { /// 排班 API,封装摄影师排班相关接口。 @MainActor -@Observable final class ScheduleAPI: ScheduleServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化排班 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift b/suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift index 7ffaed0..41bd0ee 100644 --- a/suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift +++ b/suixinkan/Features/Schedule/ViewModels/ScheduleViewModels.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 排班错误实体,表示表单校验失败。 enum ScheduleValidationError: LocalizedError, Equatable { @@ -26,14 +26,13 @@ enum ScheduleValidationError: LocalizedError, Equatable { /// 排班管理 ViewModel,负责月份日期标记、某日列表和删除操作。 @MainActor -@Observable -final class ScheduleManagementViewModel { - var monthDate = Date.scheduleFirstDayOfMonth - var selectedDate = Date() - private(set) var markedDays: Set = [] - private(set) var items: [ScheduleItem] = [] - private(set) var loading = false - var errorMessage: String? +final class ScheduleManagementViewModel: ObservableObject { + @Published var monthDate = Date.scheduleFirstDayOfMonth + @Published var selectedDate = Date() + @Published private(set) var markedDays: Set = [] + @Published private(set) var items: [ScheduleItem] = [] + @Published private(set) var loading = false + @Published var errorMessage: String? /// 切换到上一个月并重新加载。 func previousMonth(api: any ScheduleServing, scenicId: Int?) async { @@ -109,13 +108,12 @@ final class ScheduleManagementViewModel { /// 新增排班 ViewModel,负责可选订单加载、表单校验和提交。 @MainActor -@Observable -final class ScheduleAddViewModel { - var draft = ScheduleDraft() - private(set) var availableOrders: [AvailableOrderResponse] = [] - private(set) var loadingOrders = false - private(set) var submitting = false - var errorMessage: String? +final class ScheduleAddViewModel: ObservableObject { + @Published var draft = ScheduleDraft() + @Published private(set) var availableOrders: [AvailableOrderResponse] = [] + @Published private(set) var loadingOrders = false + @Published private(set) var submitting = false + @Published var errorMessage: String? /// 加载可关联订单列表。 func loadOrders(api: any ScheduleServing, scenicId: Int?) async { diff --git a/suixinkan/Features/Schedule/Views/ScheduleViews.swift b/suixinkan/Features/Schedule/Views/ScheduleViews.swift index 558d7d9..460746e 100644 --- a/suixinkan/Features/Schedule/Views/ScheduleViews.swift +++ b/suixinkan/Features/Schedule/Views/ScheduleViews.swift @@ -9,15 +9,15 @@ import SwiftUI /// 排班管理页面,展示月视图日期标记和当天排班列表。 struct ScheduleManagementView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(ScheduleAPI.self) private var scheduleAPI + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.scheduleAPI) private var scheduleAPI @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = ScheduleManagementViewModel() + @StateObject private var viewModel = ScheduleManagementViewModel() var body: some View { VStack(spacing: 0) { if accountContext.currentScenic?.id == nil { - ContentUnavailableView("缺少景区", systemImage: "calendar.badge.exclamationmark", description: Text("请选择景区后再查看排班。")) + AppContentUnavailableView("缺少景区", systemImage: "calendar.badge.exclamationmark", description: Text("请选择景区后再查看排班。")) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { @@ -96,7 +96,7 @@ struct ScheduleManagementView: View { Text(viewModel.selectedDate.scheduleDayText) .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) if viewModel.items.isEmpty { - ContentUnavailableView("当天暂无日程", systemImage: "calendar") + AppContentUnavailableView("当天暂无日程", systemImage: "calendar") .frame(minHeight: 180) } else { ForEach(viewModel.items) { item in @@ -126,10 +126,10 @@ struct ScheduleManagementView: View { /// 新增排班页面,支持手动录入和关联订单。 struct ScheduleAddView: View { @Environment(\.dismiss) private var dismiss - @Environment(AccountContext.self) private var accountContext - @Environment(ScheduleAPI.self) private var scheduleAPI + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.scheduleAPI) private var scheduleAPI @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = ScheduleAddViewModel() + @StateObject private var viewModel = ScheduleAddViewModel() @State private var date = Date() @State private var startTime = Date() @State private var endTime = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date() diff --git a/suixinkan/Features/Statistics/API/StatisticsAPI.swift b/suixinkan/Features/Statistics/API/StatisticsAPI.swift index 11efa1f..0d86b8e 100644 --- a/suixinkan/Features/Statistics/API/StatisticsAPI.swift +++ b/suixinkan/Features/Statistics/API/StatisticsAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine @MainActor /// 数据统计服务协议,抽象汇总和每日明细读取能力以便测试替换。 @@ -26,10 +26,9 @@ protocol StatisticsServing { } @MainActor -@Observable /// 数据统计 API,封装摄影师和景区管理员两套统计接口。 final class StatisticsAPI: StatisticsServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化数据统计 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift index 4d90a9d..e2c1d69 100644 --- a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift +++ b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift @@ -6,21 +6,20 @@ // import Foundation -import Observation +import Combine @MainActor -@Observable /// 数据统计页面 ViewModel,管理时间段、汇总数据、每日明细和分页状态。 -final class StatisticsViewModel { - var selectedPeriod: StatisticsPeriod = .today - private(set) var loading = false - private(set) var loadingMore = false - private(set) var summary = StatisticsSummaryResponse() - private(set) var dailyItems: [StatisticsDailyItem] = [] - private(set) var totalDailyCount = 0 - private(set) var lastRefreshAt: Date? +final class StatisticsViewModel: ObservableObject { + @Published var selectedPeriod: StatisticsPeriod = .today + @Published private(set) var loading = false + @Published private(set) var loadingMore = false + @Published private(set) var summary = StatisticsSummaryResponse() + @Published private(set) var dailyItems: [StatisticsDailyItem] = [] + @Published private(set) var totalDailyCount = 0 + @Published private(set) var lastRefreshAt: Date? - private var page = 1 + @Published private var page = 1 private let pageSize = 15 /// 当前日数据是否还有下一页。 diff --git a/suixinkan/Features/Statistics/Views/StatisticsView.swift b/suixinkan/Features/Statistics/Views/StatisticsView.swift index 008f2dd..2e7ceee 100644 --- a/suixinkan/Features/Statistics/Views/StatisticsView.swift +++ b/suixinkan/Features/Statistics/Views/StatisticsView.swift @@ -9,14 +9,14 @@ import SwiftUI /// 数据 Tab 根视图,展示订单统计汇总和每日明细。 struct StatisticsView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(PermissionContext.self) private var permissionContext - @Environment(StatisticsAPI.self) private var statisticsAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var permissionContext: PermissionContext + @Environment(\.statisticsAPI) private var statisticsAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var viewModel = StatisticsViewModel() + @StateObject private var viewModel = StatisticsViewModel() private var currentScenicId: Int? { accountContext.currentScenic?.id @@ -33,7 +33,7 @@ struct StatisticsView: View { var body: some View { Group { if currentScenicId == nil { - ContentUnavailableView( + AppContentUnavailableView( "缺少经营上下文", systemImage: "chart.bar.doc.horizontal", description: Text("请先在首页选择景区后查看数据看板。") @@ -59,10 +59,10 @@ struct StatisticsView: View { .navigationTitle("数据") .navigationBarTitleDisplayMode(.inline) .task { await reload() } - .onChange(of: accountContext.currentScenic?.id) { _, _ in + .onChange(of: accountContext.currentScenic?.id) { _ in Task { await reload() } } - .onChange(of: permissionContext.currentRole?.id) { _, _ in + .onChange(of: permissionContext.currentRole?.id) { _ in Task { await reload() } } } @@ -171,7 +171,7 @@ struct StatisticsView: View { .frame(maxWidth: .infinity) .frame(minHeight: 280) } else if viewModel.dailyItems.isEmpty { - ContentUnavailableView("暂无数据", systemImage: "tray", description: Text("可切换时间范围或下拉刷新。")) + AppContentUnavailableView("暂无数据", systemImage: "tray", description: Text("可切换时间范围或下拉刷新。")) .frame(minHeight: 280) } else { VStack(spacing: 0) { @@ -318,9 +318,9 @@ struct StatisticsView: View { #Preview { NavigationStack { StatisticsView() - .environment(AccountContext()) - .environment(PermissionContext()) - .environment(StatisticsAPI(client: APIClient())) - .environment(ToastCenter()) + .environmentObject(AccountContext()) + .environmentObject(PermissionContext()) + .environment(\.statisticsAPI, StatisticsAPI(client: APIClient())) + .environmentObject(ToastCenter()) } } diff --git a/suixinkan/Features/Tasks/API/TaskAPI.swift b/suixinkan/Features/Tasks/API/TaskAPI.swift index 475dc91..a526b05 100644 --- a/suixinkan/Features/Tasks/API/TaskAPI.swift +++ b/suixinkan/Features/Tasks/API/TaskAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 任务服务协议,抽象任务列表、详情、发布和云盘选择接口以便测试替换。 @MainActor @@ -44,9 +44,8 @@ protocol TaskServing { /// 任务 API,封装任务管理和发布任务相关接口。 @MainActor -@Observable final class TaskAPI: TaskServing { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化任务 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift b/suixinkan/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift index 06bc760..3d4045f 100644 --- a/suixinkan/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift +++ b/suixinkan/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 云盘文件类型筛选实体,表示发布任务选择附件时的过滤方式。 enum TaskCloudFileFilter: Int, CaseIterable, Identifiable { @@ -31,18 +31,17 @@ enum TaskCloudFileFilter: Int, CaseIterable, Identifiable { /// 云盘文件选择 ViewModel,负责目录浏览、筛选、分页和附件选择。 @MainActor -@Observable -final class TaskCloudFileSelectionViewModel { - var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] - var files: [CloudDriveFile] = [] - var total = 0 - var page = 1 - var searchText = "" - var selectedFilter: TaskCloudFileFilter = .all - var selectedFiles: [CloudDriveFile] = [] - var isLoading = false - var isLoadingMore = false - var errorMessage: String? +final class TaskCloudFileSelectionViewModel: ObservableObject { + @Published var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] + @Published var files: [CloudDriveFile] = [] + @Published var total = 0 + @Published var page = 1 + @Published var searchText = "" + @Published var selectedFilter: TaskCloudFileFilter = .all + @Published var selectedFiles: [CloudDriveFile] = [] + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var errorMessage: String? private let pageSize = 20 diff --git a/suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift b/suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift index 7e19b85..e9c1526 100644 --- a/suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift +++ b/suixinkan/Features/Tasks/ViewModels/TaskCreateViewModel.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 任务云盘选择项实体,表示发布任务表单中的云盘附件。 struct TaskCloudSelectionItem: Identifiable, Hashable { @@ -43,19 +43,18 @@ struct TaskLocalUploadItem: Identifiable, Hashable { /// 发布任务 ViewModel,负责表单、附件上传和提交任务。 @MainActor -@Observable -final class TaskCreateViewModel { - var taskName = "" - var remark = "" - var urgentHourText = "0" - var selectedOrder: AvailableOrderResponse? - var availableOrders: [AvailableOrderResponse] = [] - var selectedCloudFiles: [TaskCloudSelectionItem] = [] - var selectedLocalFiles: [TaskLocalUploadItem] = [] - var isLoadingOrders = false - var isSubmitting = false - var errorMessage: String? - var didSubmitSuccessfully = false +final class TaskCreateViewModel: ObservableObject { + @Published var taskName = "" + @Published var remark = "" + @Published var urgentHourText = "0" + @Published var selectedOrder: AvailableOrderResponse? + @Published var availableOrders: [AvailableOrderResponse] = [] + @Published var selectedCloudFiles: [TaskCloudSelectionItem] = [] + @Published var selectedLocalFiles: [TaskLocalUploadItem] = [] + @Published var isLoadingOrders = false + @Published var isSubmitting = false + @Published var errorMessage: String? + @Published var didSubmitSuccessfully = false /// 判断当前表单是否满足提交按钮启用条件。 var canSubmit: Bool { diff --git a/suixinkan/Features/Tasks/ViewModels/TaskManagementViewModel.swift b/suixinkan/Features/Tasks/ViewModels/TaskManagementViewModel.swift index fe6c66e..cd3d214 100644 --- a/suixinkan/Features/Tasks/ViewModels/TaskManagementViewModel.swift +++ b/suixinkan/Features/Tasks/ViewModels/TaskManagementViewModel.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 任务状态筛选实体,表示任务管理页支持的任务状态。 enum TaskStatusFilter: Int, CaseIterable, Identifiable { @@ -37,18 +37,17 @@ enum TaskStatusFilter: Int, CaseIterable, Identifiable { /// 任务管理 ViewModel,负责任务列表、筛选、分页和错误状态。 @MainActor -@Observable -final class TaskManagementViewModel { - var isLoading = false - var isLoadingMore = false - var tasks: [PhotographerTaskItem] = [] - var total = 0 - var page = 1 - var selectedStatus: TaskStatusFilter = .all - var searchText = "" - var startDate: Date? - var endDate: Date? - var errorMessage: String? +final class TaskManagementViewModel: ObservableObject { + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var tasks: [PhotographerTaskItem] = [] + @Published var total = 0 + @Published var page = 1 + @Published var selectedStatus: TaskStatusFilter = .all + @Published var searchText = "" + @Published var startDate: Date? + @Published var endDate: Date? + @Published var errorMessage: String? private let pageSize = 10 @@ -146,11 +145,10 @@ final class TaskManagementViewModel { /// 任务详情 ViewModel,负责详情加载和列表摘要兜底展示。 @MainActor -@Observable -final class TaskDetailViewModel { - var isLoading = false - var detail: TaskDetailResponse? - var errorMessage: String? +final class TaskDetailViewModel: ObservableObject { + @Published var isLoading = false + @Published var detail: TaskDetailResponse? + @Published var errorMessage: String? /// 加载任务详情;接口失败时保留外部传入的列表摘要。 func load(api: any TaskServing, taskId: Int) async { diff --git a/suixinkan/Features/Tasks/Views/TaskCreateView.swift b/suixinkan/Features/Tasks/Views/TaskCreateView.swift index 06fd9e0..d9cd90f 100644 --- a/suixinkan/Features/Tasks/Views/TaskCreateView.swift +++ b/suixinkan/Features/Tasks/Views/TaskCreateView.swift @@ -11,14 +11,14 @@ import UniformTypeIdentifiers /// 发布任务页面,负责填写任务信息、选择附件并提交任务。 struct TaskCreateView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(TaskAPI.self) private var taskAPI - @Environment(OSSUploadService.self) private var ossUploadService - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.taskAPI) private var taskAPI + @Environment(\.ossUploadService) private var ossUploadService + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = TaskCreateViewModel() + @StateObject private var viewModel = TaskCreateViewModel() @State private var showOrderSelection = false @State private var showCloudSelection = false @State private var pickerItems: [PhotosPickerItem] = [] @@ -60,7 +60,7 @@ struct TaskCreateView: View { } } } - .onChange(of: pickerItems) { _, items in + .onChange(of: pickerItems) { items in Task { await importPickerItems(items) } } } diff --git a/suixinkan/Features/Tasks/Views/TaskDetailView.swift b/suixinkan/Features/Tasks/Views/TaskDetailView.swift index 50cdecb..7eb8076 100644 --- a/suixinkan/Features/Tasks/Views/TaskDetailView.swift +++ b/suixinkan/Features/Tasks/Views/TaskDetailView.swift @@ -9,14 +9,14 @@ import SwiftUI /// 任务详情页面,展示任务基础信息、关联订单和任务结果。 struct TaskDetailView: View { - @Environment(TaskAPI.self) private var taskAPI - @Environment(ToastCenter.self) private var toastCenter + @Environment(\.taskAPI) private var taskAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading let taskId: Int let summary: PhotographerTaskItem? - @State private var viewModel = TaskDetailViewModel() + @StateObject private var viewModel = TaskDetailViewModel() var body: some View { ScrollView { diff --git a/suixinkan/Features/Tasks/Views/TaskManagementView.swift b/suixinkan/Features/Tasks/Views/TaskManagementView.swift index 032a9e2..0cbb710 100644 --- a/suixinkan/Features/Tasks/Views/TaskManagementView.swift +++ b/suixinkan/Features/Tasks/Views/TaskManagementView.swift @@ -9,13 +9,13 @@ import SwiftUI /// 任务管理页面,展示任务列表、筛选条件和发布任务入口。 struct TaskManagementView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(RouterPath.self) private var router - @Environment(TaskAPI.self) private var taskAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @EnvironmentObject private var router: RouterPath + @Environment(\.taskAPI) private var taskAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading - @State private var viewModel = TaskManagementViewModel() + @StateObject private var viewModel = TaskManagementViewModel() var body: some View { ScrollView { @@ -55,7 +55,7 @@ struct TaskManagementView: View { } } .pickerStyle(.segmented) - .onChange(of: viewModel.selectedStatus) { _, _ in + .onChange(of: viewModel.selectedStatus) { _ in Task { await reload(showLoading: true) } } diff --git a/suixinkan/Features/Tasks/Views/TaskSelectionViews.swift b/suixinkan/Features/Tasks/Views/TaskSelectionViews.swift index 11bdd5f..7cc1917 100644 --- a/suixinkan/Features/Tasks/Views/TaskSelectionViews.swift +++ b/suixinkan/Features/Tasks/Views/TaskSelectionViews.swift @@ -57,13 +57,13 @@ struct TaskOrderSelectionView: View { /// 任务云盘文件选择页面,供发布任务时选择云盘附件。 struct TaskCloudFileSelectionView: View { - @Environment(TaskAPI.self) private var taskAPI - @Environment(ToastCenter.self) private var toastCenter + @Environment(\.taskAPI) private var taskAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss let onConfirm: ([TaskCloudSelectionItem]) -> Void - @State private var viewModel = TaskCloudFileSelectionViewModel() + @StateObject private var viewModel = TaskCloudFileSelectionViewModel() var body: some View { VStack(spacing: 0) { @@ -108,7 +108,7 @@ struct TaskCloudFileSelectionView: View { } } .pickerStyle(.segmented) - .onChange(of: viewModel.selectedFilter) { _, _ in + .onChange(of: viewModel.selectedFilter) { _ in Task { await reload() } } } diff --git a/suixinkan/Features/Wallet/API/WalletAPI.swift b/suixinkan/Features/Wallet/API/WalletAPI.swift index f17cabf..20ff16c 100644 --- a/suixinkan/Features/Wallet/API/WalletAPI.swift +++ b/suixinkan/Features/Wallet/API/WalletAPI.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 钱包模块服务协议,定义钱包、提现、银行卡和积分接口能力。 @MainActor @@ -55,10 +55,9 @@ protocol WalletServing { } @MainActor -@Observable /// 钱包 API,封装个人钱包、提现、银行卡和积分兑换网络请求。 final class WalletAPI { - @ObservationIgnored private let client: APIClient + private let client: APIClient /// 初始化钱包 API,并注入共享网络客户端。 init(client: APIClient) { diff --git a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift index 0a175f4..e600ce2 100644 --- a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift +++ b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine @MainActor /// 钱包实名认证服务协议,抽象实名认证状态读取能力以便测试替换。 @@ -34,25 +34,24 @@ enum WalletLedgerTab: String, CaseIterable, Identifiable { } @MainActor -@Observable /// 钱包首页 ViewModel,管理钱包汇总、收益明细、提现记录和提现资格流。 -final class WalletViewModel { - var summary: WalletSummaryResponse? - var pointsOverview: PointOverviewResponse? - var selectedTab: WalletLedgerTab = .earnings - var selectedFilter: WalletDateFilter = .last30 - var earningsGroups: [WalletEarningDetailGroup] = [] - var withdrawRecords: [WalletWithdrawRecord] = [] - var isLoadingSummary = false - var isLoadingList = false - var isCheckingWithdraw = false - var errorMessage: String? +final class WalletViewModel: ObservableObject { + @Published var summary: WalletSummaryResponse? + @Published var pointsOverview: PointOverviewResponse? + @Published var selectedTab: WalletLedgerTab = .earnings + @Published var selectedFilter: WalletDateFilter = .last30 + @Published var earningsGroups: [WalletEarningDetailGroup] = [] + @Published var withdrawRecords: [WalletWithdrawRecord] = [] + @Published var isLoadingSummary = false + @Published var isLoadingList = false + @Published var isCheckingWithdraw = false + @Published var errorMessage: String? private let pageSize = 20 - private var earningsPage = 1 - private var earningsTotal = 0 - private var withdrawPage = 1 - private var withdrawTotal = 0 + @Published private var earningsPage = 1 + @Published private var earningsTotal = 0 + @Published private var withdrawPage = 1 + @Published private var withdrawTotal = 0 /// 钱包可提现金额展示文本。 var withdrawableText: String { @@ -225,16 +224,15 @@ final class WalletViewModel { } @MainActor -@Observable /// 提现申请 ViewModel,管理提现信息、金额校验、短信验证码和提交。 -final class WithdrawApplyViewModel { - var info: WithdrawInfoResponse? - var amountText = "" - var smsCode = "" - var isLoading = false - var isSubmitting = false - var smsCountdown = 0 - var errorMessage: String? +final class WithdrawApplyViewModel: ObservableObject { + @Published var info: WithdrawInfoResponse? + @Published var amountText = "" + @Published var smsCode = "" + @Published var isLoading = false + @Published var isSubmitting = false + @Published var smsCountdown = 0 + @Published var errorMessage: String? /// 加载提现申请所需信息。 func load(api: WalletServing) async { @@ -294,28 +292,27 @@ final class WithdrawApplyViewModel { } @MainActor -@Observable /// 银行卡设置 ViewModel,管理银行卡表单、图片上传、短信验证码和提交。 -final class WithdrawalSettingsViewModel { - var bankCard: WalletBankCardInfo? - var banks: [String] = [] - var areas: [AreaNode] = [] - var realName = "" - var cardNumber = "" - var bankName = "" - var branchName = "" - var provinceCode = "" - var cityCode = "" - var smsCode = "" - var frontImageData: Data? - var backImageData: Data? - var frontImageURL = "" - var backImageURL = "" - var uploadProgress = 0 - var isLoading = false - var isSubmitting = false - var smsCountdown = 0 - var errorMessage: String? +final class WithdrawalSettingsViewModel: ObservableObject { + @Published var bankCard: WalletBankCardInfo? + @Published var banks: [String] = [] + @Published var areas: [AreaNode] = [] + @Published var realName = "" + @Published var cardNumber = "" + @Published var bankName = "" + @Published var branchName = "" + @Published var provinceCode = "" + @Published var cityCode = "" + @Published var smsCode = "" + @Published var frontImageData: Data? + @Published var backImageData: Data? + @Published var frontImageURL = "" + @Published var backImageURL = "" + @Published var uploadProgress = 0 + @Published var isLoading = false + @Published var isSubmitting = false + @Published var smsCountdown = 0 + @Published var errorMessage: String? /// 加载银行卡设置需要的银行、地区和已提交资料。 func load(api: WalletServing) async { @@ -440,19 +437,18 @@ final class WithdrawalSettingsViewModel { } @MainActor -@Observable /// 积分兑换 ViewModel,管理积分概览、兑换记录、兑换金额校验和提交。 -final class PointsRedemptionViewModel { - var overview = PointOverviewResponse() - var records: [PointWithdrawItem] = [] - var pointsText = "" - var isLoading = false - var isSubmitting = false - var errorMessage: String? +final class PointsRedemptionViewModel: ObservableObject { + @Published var overview = PointOverviewResponse() + @Published var records: [PointWithdrawItem] = [] + @Published var pointsText = "" + @Published var isLoading = false + @Published var isSubmitting = false + @Published var errorMessage: String? private let pageSize = 20 - private var page = 1 - private var total = 0 + @Published private var page = 1 + @Published private var total = 0 /// 是否还能继续加载兑换记录。 var canLoadMore: Bool { diff --git a/suixinkan/Features/Wallet/Views/WalletView.swift b/suixinkan/Features/Wallet/Views/WalletView.swift index 5a7743e..6818504 100644 --- a/suixinkan/Features/Wallet/Views/WalletView.swift +++ b/suixinkan/Features/Wallet/Views/WalletView.swift @@ -11,12 +11,12 @@ import SwiftUI /// 钱包首页,展示钱包汇总、收益明细、提现记录和钱包二级入口。 struct WalletView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(WalletAPI.self) private var walletAPI - @Environment(ProfileAPI.self) private var profileAPI - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.walletAPI) private var walletAPI + @Environment(\.profileAPI) private var profileAPI + @EnvironmentObject private var toastCenter: ToastCenter - @State private var viewModel = WalletViewModel() + @StateObject private var viewModel = WalletViewModel() @State private var route: WalletRoute? var body: some View { @@ -33,7 +33,7 @@ struct WalletView: View { .background(Color(hex: 0xF5F7FA)) .navigationTitle("我的钱包") .navigationBarTitleDisplayMode(.inline) - .navigationDestination(item: $route) { route in + .appNavigationDestination(item: $route) { route in switch route { case .withdrawApply: WithdrawApplyView() @@ -51,7 +51,7 @@ struct WalletView: View { .refreshable { await viewModel.loadInitial(api: walletAPI, staffId: staffId) } - .onChange(of: viewModel.errorMessage) { _, message in + .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } @@ -256,10 +256,10 @@ struct WalletView: View { /// 提现申请页,输入金额和短信验证码后提交提现。 struct WithdrawApplyView: View { - @Environment(WalletAPI.self) private var walletAPI - @Environment(ToastCenter.self) private var toastCenter + @Environment(\.walletAPI) private var walletAPI + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss - @State private var viewModel = WithdrawApplyViewModel() + @StateObject private var viewModel = WithdrawApplyViewModel() var body: some View { Form { @@ -301,7 +301,7 @@ struct WithdrawApplyView: View { viewModel.smsCountdown -= 1 } } - .onChange(of: viewModel.errorMessage) { _, message in + .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } @@ -311,13 +311,13 @@ struct WithdrawApplyView: View { /// 银行卡设置页,使用图片选择和 OSS 上传提交银行卡资料。 struct WithdrawalSettingsView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(WalletAPI.self) private var walletAPI - @Environment(OSSUploadService.self) private var ossUploadService - @Environment(ToastCenter.self) private var toastCenter + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.walletAPI) private var walletAPI + @Environment(\.ossUploadService) private var ossUploadService + @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss - @State private var viewModel = WithdrawalSettingsViewModel() + @StateObject private var viewModel = WithdrawalSettingsViewModel() @State private var frontItem: PhotosPickerItem? @State private var backItem: PhotosPickerItem? @@ -389,10 +389,10 @@ struct WithdrawalSettingsView: View { .navigationTitle("银行卡设置") .navigationBarTitleDisplayMode(.inline) .task { await viewModel.load(api: walletAPI) } - .onChange(of: frontItem) { _, item in + .onChange(of: frontItem) { item in Task { viewModel.frontImageData = try? await item?.loadTransferable(type: Data.self) } } - .onChange(of: backItem) { _, item in + .onChange(of: backItem) { item in Task { viewModel.backImageData = try? await item?.loadTransferable(type: Data.self) } } .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in @@ -400,7 +400,7 @@ struct WithdrawalSettingsView: View { viewModel.smsCountdown -= 1 } } - .onChange(of: viewModel.errorMessage) { _, message in + .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } @@ -449,10 +449,10 @@ struct WithdrawalSettingsView: View { /// 积分兑换页,展示积分概览、提交兑换申请和兑换记录。 struct PointsRedemptionView: View { - @Environment(AccountContext.self) private var accountContext - @Environment(WalletAPI.self) private var walletAPI - @Environment(ToastCenter.self) private var toastCenter - @State private var viewModel = PointsRedemptionViewModel() + @EnvironmentObject private var accountContext: AccountContext + @Environment(\.walletAPI) private var walletAPI + @EnvironmentObject private var toastCenter: ToastCenter + @StateObject private var viewModel = PointsRedemptionViewModel() var body: some View { ScrollView { @@ -517,7 +517,7 @@ struct PointsRedemptionView: View { .navigationTitle("积分兑换") .navigationBarTitleDisplayMode(.inline) .task { await viewModel.load(api: walletAPI, staffId: staffId) } - .onChange(of: viewModel.errorMessage) { _, message in + .onChange(of: viewModel.errorMessage) { message in if let message { toastCenter.show(message) } diff --git a/suixinkan/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift b/suixinkan/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift index 4baaa53..885a1bf 100644 --- a/suixinkan/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift +++ b/suixinkan/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift @@ -6,7 +6,7 @@ // import Foundation -import Observation +import Combine /// 提现审核筛选项,按提现记录的状态文案聚合审核视角。 enum WithdrawalAuditFilter: String, CaseIterable, Identifiable { @@ -27,20 +27,19 @@ enum WithdrawalAuditFilter: String, CaseIterable, Identifiable { } @MainActor -@Observable /// 提现审核 ViewModel,负责提现记录分页、状态筛选和失败状态清理。 -final class WithdrawalAuditViewModel { - var records: [WalletWithdrawRecord] = [] - var selectedFilter: WithdrawalAuditFilter = .all - var isLoading = false - var isLoadingMore = false - var loadFailed = false - var loadFailureReason: String? - var errorMessage: String? +final class WithdrawalAuditViewModel: ObservableObject { + @Published var records: [WalletWithdrawRecord] = [] + @Published var selectedFilter: WithdrawalAuditFilter = .all + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var loadFailed = false + @Published var loadFailureReason: String? + @Published var errorMessage: String? private let pageSize = 10 - private var page = 1 - private var total = 0 + @Published private var page = 1 + @Published private var total = 0 /// 当前筛选下的提现记录。 var filteredRecords: [WalletWithdrawRecord] { diff --git a/suixinkan/Features/WithdrawalAudit/Views/WithdrawalAuditView.swift b/suixinkan/Features/WithdrawalAudit/Views/WithdrawalAuditView.swift index 00115a5..47c21aa 100644 --- a/suixinkan/Features/WithdrawalAudit/Views/WithdrawalAuditView.swift +++ b/suixinkan/Features/WithdrawalAudit/Views/WithdrawalAuditView.swift @@ -9,9 +9,9 @@ import SwiftUI /// 提现审核列表页,以审核视角展示钱包提现记录和处理进度。 struct WithdrawalAuditView: View { - @Environment(WalletAPI.self) private var walletAPI - @Environment(ToastCenter.self) private var toastCenter - @State private var viewModel = WithdrawalAuditViewModel() + @Environment(\.walletAPI) private var walletAPI + @EnvironmentObject private var toastCenter: ToastCenter + @StateObject private var viewModel = WithdrawalAuditViewModel() @State private var selectedRecord: WalletWithdrawRecord? var body: some View { @@ -37,7 +37,7 @@ struct WithdrawalAuditView: View { WithdrawalAuditDetailView(record: record) } } - .onChange(of: viewModel.errorMessage) { _, message in + .onChange(of: viewModel.errorMessage) { message in guard let message else { return } toastCenter.show(message) viewModel.errorMessage = nil @@ -82,7 +82,7 @@ struct WithdrawalAuditView: View { .background(.white, in: RoundedRectangle(cornerRadius: 12)) } else if viewModel.loadFailed && viewModel.records.isEmpty { VStack(spacing: AppMetrics.Spacing.small) { - ContentUnavailableView( + AppContentUnavailableView( "提现审核记录加载失败", systemImage: "exclamationmark.triangle", description: Text(viewModel.loadFailureReason ?? "请检查网络后重试") @@ -100,7 +100,7 @@ struct WithdrawalAuditView: View { .frame(maxWidth: .infinity, minHeight: 220) .background(.white, in: RoundedRectangle(cornerRadius: 12)) } else if viewModel.filteredRecords.isEmpty { - ContentUnavailableView("暂无提现审核记录", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新")) + AppContentUnavailableView("暂无提现审核记录", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新")) .frame(maxWidth: .infinity, minHeight: 220) .background(.white, in: RoundedRectangle(cornerRadius: 12)) } else { diff --git a/suixinkanUITests/LoginSmokeUITests.swift b/suixinkanUITests/LoginSmokeUITests.swift new file mode 100644 index 0000000..08f8012 --- /dev/null +++ b/suixinkanUITests/LoginSmokeUITests.swift @@ -0,0 +1,28 @@ +// +// LoginSmokeUITests.swift +// suixinkanUITests +// +// Created by Codex on 2026/6/26. +// + +import XCTest + +final class LoginSmokeUITests: XCTestCase { + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testFreshLaunchShowsLoginScreen() { + let app = XCUIApplication() + app.launchArguments.append("-suixinkan-ui-tests-reset-state") + app.launch() + + XCTAssertTrue(app.staticTexts["login.title"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.textFields["login.username"].exists) + XCTAssertTrue(app.secureTextFields["login.password"].exists || app.textFields["login.password"].exists) + XCTAssertTrue(app.buttons["login.submit"].exists) + XCTAssertTrue(app.buttons["login.userAgreement"].exists) + XCTAssertTrue(app.buttons["login.privacyPolicy"].exists) + } +}