从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构

将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

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

337
iOS16兼容迁移记录.md Normal file
View File

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

View File

@ -22,6 +22,13 @@
remoteGlobalIDString = 939AC7952FE3F832004B22E4; remoteGlobalIDString = 939AC7952FE3F832004B22E4;
remoteInfo = suixinkan; remoteInfo = suixinkan;
}; };
B00000072FEF000000000007 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 939AC78E2FE3F832004B22E4 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 939AC7952FE3F832004B22E4;
remoteInfo = suixinkan;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXFileReference 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 = "<group>"; }; 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 = "<group>"; };
939AC7962FE3F832004B22E4 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; 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 = "<group>"; }; 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 = "<group>"; };
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 = "<group>"; }; 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 = "<group>"; };
EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkanTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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; path = suixinkanTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B00000022FEF000000000002 /* suixinkanUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = suixinkanUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -81,6 +94,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B00000052FEF000000000005 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@ -89,6 +109,7 @@
children = ( children = (
939AC7982FE3F832004B22E4 /* suixinkan */, 939AC7982FE3F832004B22E4 /* suixinkan */,
A00000022FE9000000000002 /* suixinkanTests */, A00000022FE9000000000002 /* suixinkanTests */,
B00000022FEF000000000002 /* suixinkanUITests */,
939AC7972FE3F832004B22E4 /* Products */, 939AC7972FE3F832004B22E4 /* Products */,
C7EEB11765222926FB482A0E /* Pods */, C7EEB11765222926FB482A0E /* Pods */,
F6ABC23A28D8DF8126F325ED /* Frameworks */, F6ABC23A28D8DF8126F325ED /* Frameworks */,
@ -100,6 +121,7 @@
children = ( children = (
939AC7962FE3F832004B22E4 /* suixinkan.app */, 939AC7962FE3F832004B22E4 /* suixinkan.app */,
A00000012FE9000000000001 /* suixinkanTests.xctest */, A00000012FE9000000000001 /* suixinkanTests.xctest */,
B00000012FEF000000000001 /* suixinkanUITests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -176,6 +198,27 @@
productReference = A00000012FE9000000000001 /* suixinkanTests.xctest */; productReference = A00000012FE9000000000001 /* suixinkanTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test"; 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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@ -193,6 +236,10 @@
CreatedOnToolsVersion = 26.5; CreatedOnToolsVersion = 26.5;
TestTargetID = 939AC7952FE3F832004B22E4; TestTargetID = 939AC7952FE3F832004B22E4;
}; };
B00000032FEF000000000003 = {
CreatedOnToolsVersion = 26.5;
TestTargetID = 939AC7952FE3F832004B22E4;
};
}; };
}; };
buildConfigurationList = 939AC7912FE3F832004B22E4 /* Build configuration list for PBXProject "suixinkan" */; buildConfigurationList = 939AC7912FE3F832004B22E4 /* Build configuration list for PBXProject "suixinkan" */;
@ -216,6 +263,7 @@
targets = ( targets = (
939AC7952FE3F832004B22E4 /* suixinkan */, 939AC7952FE3F832004B22E4 /* suixinkan */,
A00000032FE9000000000003 /* suixinkanTests */, A00000032FE9000000000003 /* suixinkanTests */,
B00000032FEF000000000003 /* suixinkanUITests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -235,6 +283,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B00000062FEF000000000006 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
@ -320,6 +375,13 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
B00000042FEF000000000004 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@ -328,6 +390,11 @@
target = 939AC7952FE3F832004B22E4 /* suixinkan */; target = 939AC7952FE3F832004B22E4 /* suixinkan */;
targetProxy = A00000072FE9000000000007 /* PBXContainerItemProxy */; targetProxy = A00000072FE9000000000007 /* PBXContainerItemProxy */;
}; };
B00000082FEF000000000008 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 939AC7952FE3F832004B22E4 /* suixinkan */;
targetProxy = B00000072FEF000000000007 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@ -467,7 +534,7 @@
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = suixinkan/Info.plist; INFOPLIST_FILE = suixinkan/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -505,7 +572,7 @@
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = suixinkan/Info.plist; INFOPLIST_FILE = suixinkan/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -539,7 +606,7 @@
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -575,7 +642,7 @@
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 16;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -600,6 +667,72 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@ -630,6 +763,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
B000000B2FEF00000000000B /* Build configuration list for PBXNativeTarget "suixinkanUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B00000092FEF000000000009 /* Debug */,
B000000A2FEF00000000000A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */

View File

@ -1,5 +1,5 @@
{ {
"originHash" : "40830d6592be5c997cf1b8848d6a8791afd48ce9a5e48a1f297fbae4a6de3783", "originHash" : "6f02892b99a60872df0561ceead8949f780d607eccd0a776d8f299248fc13a72",
"pins" : [ "pins" : [
{ {
"identity" : "alibabacloud-oss-swift-sdk-v2", "identity" : "alibabacloud-oss-swift-sdk-v2",
@ -19,6 +19,15 @@
"version" : "8.10.0" "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", "identity" : "swift-asn1",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@ -35,6 +35,20 @@
ReferencedContainer = "container:suixinkan.xcodeproj"> ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildActionEntry> </BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B00000032FEF000000000003"
BuildableName = "suixinkanUITests.xctest"
BlueprintName = "suixinkanUITests"
ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries> </BuildActionEntries>
</BuildAction> </BuildAction>
<TestAction <TestAction
@ -64,6 +78,16 @@
ReferencedContainer = "container:suixinkan.xcodeproj"> ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B00000032FEF000000000003"
BuildableName = "suixinkanUITests.xctest"
BlueprintName = "suixinkanUITests"
ReferencedContainer = "container:suixinkan.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction

View File

@ -0,0 +1,261 @@
//
// AppServiceEnvironment.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SwiftUI
private enum EnvironmentServiceDefaults {
static func apiClient() -> 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 }
}
}

View File

@ -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()
}
}

View File

@ -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, Destination: View>(
item: Binding<Item?>,
@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)
}
}
}
}

View File

@ -5,7 +5,7 @@
// Created by Codex on 2026/6/18. // Created by Codex on 2026/6/18.
// //
import Observation import Combine
import SwiftUI import SwiftUI
/// Tab /// Tab
@ -60,10 +60,9 @@ extension OrdersRoute {
} }
@MainActor @MainActor
@Observable
/// NavigationStack Tab /// NavigationStack Tab
final class RouterPath { final class RouterPath: ObservableObject {
var path: [AppRoute] = [] @Published var path: [AppRoute] = []
/// Tab /// Tab
func navigate(to route: AppRoute) { func navigate(to route: AppRoute) {
@ -77,13 +76,12 @@ final class RouterPath {
} }
@MainActor @MainActor
@Observable
/// Tab Tab NavigationStack /// Tab Tab NavigationStack
final class AppRouter { final class AppRouter: ObservableObject {
var selectedTab: AppTab = .home @Published var selectedTab: AppTab = .home
var selectedOrdersEntry: OrdersEntry = .storeOrders @Published var selectedOrdersEntry: OrdersEntry = .storeOrders
private(set) var pendingOrderScanCode: String? @Published private(set) var pendingOrderScanCode: String?
private var routers: [AppTab: RouterPath] = [:] @Published private var routers: [AppTab: RouterPath] = [:]
/// Tab /// Tab
func router(for tab: AppTab) -> RouterPath { func router(for tab: AppTab) -> RouterPath {

View File

@ -10,12 +10,12 @@ import SwiftUI
/// App /// App
struct RootView: View { struct RootView: View {
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@State private var appSession = AppSession() @StateObject private var appSession = AppSession()
@State private var accountContext = AccountContext() @StateObject private var accountContext = AccountContext()
@State private var permissionContext = PermissionContext() @StateObject private var permissionContext = PermissionContext()
@State private var scenicSpotContext = ScenicSpotContext() @StateObject private var scenicSpotContext = ScenicSpotContext()
@State private var appRouter = AppRouter() @StateObject private var appRouter = AppRouter()
@State private var toastCenter = ToastCenter() @StateObject private var toastCenter = ToastCenter()
@State private var globalLoading = GlobalLoadingCenter() @State private var globalLoading = GlobalLoadingCenter()
@State private var snapshotStore: AccountSnapshotStore @State private var snapshotStore: AccountSnapshotStore
@State private var apiClient: APIClient @State private var apiClient: APIClient
@ -33,7 +33,7 @@ struct RootView: View {
@State private var scenicSettlementAPI: ScenicSettlementAPI @State private var scenicSettlementAPI: ScenicSettlementAPI
@State private var messageCenterAPI: MessageCenterAPI @State private var messageCenterAPI: MessageCenterAPI
@State private var scenicQueueAPI: ScenicQueueAPI @State private var scenicQueueAPI: ScenicQueueAPI
@State private var scenicQueueRuntime = ScenicQueueRuntime() @StateObject private var scenicQueueRuntime = ScenicQueueRuntime()
@State private var liveAPI: LiveAPI @State private var liveAPI: LiveAPI
@State private var operatingAreaAPI: OperatingAreaAPI @State private var operatingAreaAPI: OperatingAreaAPI
@State private var pilotCertificationAPI: PilotCertificationAPI @State private var pilotCertificationAPI: PilotCertificationAPI
@ -98,41 +98,41 @@ struct RootView: View {
rootContent rootContent
.globalToastOverlay() .globalToastOverlay()
.globalLoadingOverlay(loadingCenter: globalLoading) .globalLoadingOverlay(loadingCenter: globalLoading)
.environment(appSession) .environmentObject(appSession)
.environment(accountContext) .environmentObject(accountContext)
.environment(permissionContext) .environmentObject(permissionContext)
.environment(scenicSpotContext) .environmentObject(scenicSpotContext)
.environment(appRouter) .environmentObject(appRouter)
.environment(toastCenter) .environmentObject(toastCenter)
.environment(\.globalLoading, globalLoading) .environment(\.globalLoading, globalLoading)
.environment(snapshotStore) .environment(\.accountSnapshotStore, snapshotStore)
.environment(apiClient) .environment(\.apiClient, apiClient)
.environment(authAPI) .environment(\.authAPI, authAPI)
.environment(profileAPI) .environment(\.profileAPI, profileAPI)
.environment(uploadAPI) .environment(\.uploadAPI, uploadAPI)
.environment(ossUploadService) .environment(\.ossUploadService, ossUploadService)
.environment(accountContextAPI) .environment(\.accountContextAPI, accountContextAPI)
.environment(ordersAPI) .environment(\.ordersAPI, ordersAPI)
.environment(statisticsAPI) .environment(\.statisticsAPI, statisticsAPI)
.environment(paymentAPI) .environment(\.paymentAPI, paymentAPI)
.environment(walletAPI) .environment(\.walletAPI, walletAPI)
.environment(pushAPI) .environment(\.pushAPI, pushAPI)
.environment(scenicPermissionAPI) .environment(\.scenicPermissionAPI, scenicPermissionAPI)
.environment(scenicSettlementAPI) .environment(\.scenicSettlementAPI, scenicSettlementAPI)
.environment(messageCenterAPI) .environment(\.messageCenterAPI, messageCenterAPI)
.environment(scenicQueueAPI) .environment(\.scenicQueueAPI, scenicQueueAPI)
.environment(scenicQueueRuntime) .environmentObject(scenicQueueRuntime)
.environment(liveAPI) .environment(\.liveAPI, liveAPI)
.environment(operatingAreaAPI) .environment(\.operatingAreaAPI, operatingAreaAPI)
.environment(pilotCertificationAPI) .environment(\.pilotCertificationAPI, pilotCertificationAPI)
.environment(taskAPI) .environment(\.taskAPI, taskAPI)
.environment(projectAPI) .environment(\.projectAPI, projectAPI)
.environment(scheduleAPI) .environment(\.scheduleAPI, scheduleAPI)
.environment(inviteAPI) .environment(\.inviteAPI, inviteAPI)
.environment(assetsAPI) .environment(\.assetsAPI, assetsAPI)
.environment(punchPointAPI) .environment(\.punchPointAPI, punchPointAPI)
.environment(locationReportAPI) .environment(\.locationReportAPI, locationReportAPI)
.environment(authSessionCoordinator) .environment(\.authSessionCoordinator, authSessionCoordinator)
.task { .task {
apiClient.bindAuthTokenProvider { appSession.token } apiClient.bindAuthTokenProvider { appSession.token }
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter) PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
@ -160,7 +160,7 @@ struct RootView: View {
api: accountContextAPI api: accountContextAPI
) )
} }
.onChange(of: scenePhase) { _, newPhase in .onChange(of: scenePhase) { newPhase in
if appSession.isLoggedIn { if appSession.isLoggedIn {
scenicQueueRuntime.update( scenicQueueRuntime.update(
api: scenicQueueAPI, api: scenicQueueAPI,
@ -172,7 +172,7 @@ struct RootView: View {
scenicQueueRuntime.stop() scenicQueueRuntime.stop()
} }
} }
.onChange(of: accountContext.currentScenic?.id) { _, _ in .onChange(of: accountContext.currentScenic?.id) { _ in
if appSession.isLoggedIn { if appSession.isLoggedIn {
scenicQueueRuntime.update( scenicQueueRuntime.update(
api: scenicQueueAPI, api: scenicQueueAPI,
@ -182,7 +182,7 @@ struct RootView: View {
) )
} }
} }
.onChange(of: appSession.phase) { _, phase in .onChange(of: appSession.phase) { phase in
switch phase { switch phase {
case .loggedIn: case .loggedIn:
PushNotificationManager.shared.requestAuthorizationAndRegister() PushNotificationManager.shared.requestAuthorizationAndRegister()

View File

@ -5,7 +5,7 @@
// Created by Codex on 2026/6/18. // Created by Codex on 2026/6/18.
// //
import Observation import Combine
/// ///
struct AccountProfile: Codable, Equatable { struct AccountProfile: Codable, Equatable {
@ -58,14 +58,13 @@ struct BusinessScope: Codable, Equatable, Identifiable {
} }
@MainActor @MainActor
@Observable
/// / /// /
final class AccountContext { final class AccountContext: ObservableObject {
private(set) var profile: AccountProfile? @Published private(set) var profile: AccountProfile?
private(set) var scenicScopes: [BusinessScope] = [] @Published private(set) var scenicScopes: [BusinessScope] = []
private(set) var storeScopes: [BusinessScope] = [] @Published private(set) var storeScopes: [BusinessScope] = []
var currentScenic: BusinessScope? @Published var currentScenic: BusinessScope?
var currentStore: BusinessScope? @Published var currentStore: BusinessScope?
/// ///
func applyLogin(profile: AccountProfile? = nil) { func applyLogin(profile: AccountProfile? = nil) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
enum AuthPhase: Equatable { enum AuthPhase: Equatable {
@ -16,11 +16,10 @@ enum AuthPhase: Equatable {
} }
@MainActor @MainActor
@Observable
/// token /// token
final class AppSession { final class AppSession: ObservableObject {
private(set) var phase: AuthPhase = .loggedOut @Published private(set) var phase: AuthPhase = .loggedOut
private(set) var token: String? @Published private(set) var token: String?
var isLoggedIn: Bool { var isLoggedIn: Bool {
phase == .loggedIn phase == .loggedIn

View File

@ -6,15 +6,14 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// 退 /// 退
final class AuthSessionCoordinator { final class AuthSessionCoordinator {
@ObservationIgnored private let tokenStore: SessionTokenStore private let tokenStore: SessionTokenStore
@ObservationIgnored private let snapshotStore: AccountSnapshotStore private let snapshotStore: AccountSnapshotStore
@ObservationIgnored private let preferencesStore: AppPreferencesStore private let preferencesStore: AppPreferencesStore
/// token /// token
init( init(

View File

@ -6,15 +6,14 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// URI /// URI
final class PermissionContext { final class PermissionContext: ObservableObject {
private(set) var rolePermissions: [RolePermissionResponse] = [] @Published private(set) var rolePermissions: [RolePermissionResponse] = []
private(set) var permissionURIs: Set<String> = [] @Published private(set) var permissionURIs: Set<String> = []
var currentRole: RoleInfo? @Published var currentRole: RoleInfo?
/// ID /// ID
func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) { func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
enum ScenicSpotLoadState: Equatable { enum ScenicSpotLoadState: Equatable {
@ -17,12 +17,11 @@ enum ScenicSpotLoadState: Equatable {
} }
@MainActor @MainActor
@Observable
/// ///
final class ScenicSpotContext { final class ScenicSpotContext: ObservableObject {
private(set) var scenicId: Int? @Published private(set) var scenicId: Int?
private(set) var spots: [ScenicSpotItem] = [] @Published private(set) var spots: [ScenicSpotItem] = []
private(set) var loadState: ScenicSpotLoadState = .idle @Published private(set) var loadState: ScenicSpotLoadState = .idle
/// ID /// ID
func reload(scenicId: Int?, api: AccountContextServing) async { func reload(scenicId: Int?, api: AccountContextServing) async {

View File

@ -6,14 +6,13 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// token /// token
final class SessionBootstrapper { final class SessionBootstrapper {
@ObservationIgnored private let tokenStore: SessionTokenStore private let tokenStore: SessionTokenStore
@ObservationIgnored private let snapshotStore: AccountSnapshotStore private let snapshotStore: AccountSnapshotStore
private var didAttemptRestore = false private var didAttemptRestore = false
/// token /// token

View File

@ -5,17 +5,16 @@
// Created by Codex on 2026/6/18. // Created by Codex on 2026/6/18.
// //
import Observation import Combine
import SwiftUI import SwiftUI
@MainActor @MainActor
@Observable
/// Toast /// Toast
final class ToastCenter { final class ToastCenter: ObservableObject {
fileprivate var message: String? @Published fileprivate var message: String?
@ObservationIgnored private let autoDismissNanoseconds: UInt64 private let autoDismissNanoseconds: UInt64
@ObservationIgnored private var dismissTask: Task<Void, Never>? @Published private var dismissTask: Task<Void, Never>?
@ObservationIgnored private var displayToken: UInt64 = 0 @Published private var displayToken: UInt64 = 0
/// Toast 2.2 /// Toast 2.2
init(autoDismissNanoseconds: UInt64 = 2_200_000_000) { init(autoDismissNanoseconds: UInt64 = 2_200_000_000) {
@ -65,7 +64,7 @@ final class ToastCenter {
/// Toast Toast /// Toast Toast
private struct GlobalToastOverlay: ViewModifier { private struct GlobalToastOverlay: ViewModifier {
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content

View File

@ -12,6 +12,10 @@ import SwiftUI
struct suixinkanApp: App { struct suixinkanApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
AppUITestLaunchState.resetIfNeeded()
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()

View File

@ -0,0 +1,75 @@
//
// AppContentUnavailableView.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import SwiftUI
/// iOS 16 iOS 17+ 使 AppContentUnavailableView
struct AppContentUnavailableView<LabelContent: View, DescriptionContent: View, ActionsContent: View>: 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<Text, Image>, DescriptionContent == EmptyView, ActionsContent == EmptyView {
init(_ title: String, systemImage: String) {
self.init {
Label(title, systemImage: systemImage)
} description: {
EmptyView()
} actions: {
EmptyView()
}
}
}
extension AppContentUnavailableView where LabelContent == Label<Text, Image>, DescriptionContent == Text, ActionsContent == EmptyView {
init(_ title: String, systemImage: String, description: Text) {
self.init {
Label(title, systemImage: systemImage)
} description: {
description
} actions: {
EmptyView()
}
}
}

View File

@ -77,7 +77,7 @@ final class GlobalLoadingCenter {
fileprivate final class GlobalLoadingState: ObservableObject { fileprivate final class GlobalLoadingState: ObservableObject {
@Published fileprivate var isVisible = false @Published fileprivate var isVisible = false
@Published fileprivate var message = "" @Published fileprivate var message = ""
fileprivate var activeCount = 0 @Published fileprivate var activeCount = 0
} }
/// Lottie Loading loading.json /// Lottie Loading loading.json

View File

@ -7,7 +7,7 @@
import CoreLocation import CoreLocation
import Foundation import Foundation
import Observation import Combine
/// ///
struct ForegroundLocationResult: Equatable { struct ForegroundLocationResult: Equatable {
@ -18,11 +18,10 @@ struct ForegroundLocationResult: Equatable {
/// Provider /// Provider
@MainActor @MainActor
@Observable final class ForegroundLocationProvider: NSObject, ObservableObject {
final class ForegroundLocationProvider: NSObject { private let manager = CLLocationManager()
@ObservationIgnored private let manager = CLLocationManager() private let geocoder = CLGeocoder()
@ObservationIgnored private let geocoder = CLGeocoder() @Published private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
@ObservationIgnored private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
override init() { override init() {
super.init() super.init()

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// URLSession /// URLSession
protocol URLSessionProtocol { protocol URLSessionProtocol {
@ -17,13 +17,12 @@ protocol URLSessionProtocol {
extension URLSession: URLSessionProtocol {} extension URLSession: URLSessionProtocol {}
@MainActor @MainActor
@Observable
/// token Envelope /// token Envelope
final class APIClient { final class APIClient {
@ObservationIgnored private let session: URLSessionProtocol private let session: URLSessionProtocol
@ObservationIgnored private let encoder: JSONEncoder private let encoder: JSONEncoder
@ObservationIgnored private let decoder: JSONDecoder private let decoder: JSONDecoder
@ObservationIgnored private var authTokenProvider: (() -> String?)? private var authTokenProvider: (() -> String?)?
private let environment: APIEnvironment private let environment: APIEnvironment
private let appVersion: String private let appVersion: String

View File

@ -6,13 +6,12 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// API iOS APNs token /// API iOS APNs token
final class PushAPI { final class PushAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,31 +6,30 @@
// //
import AVFoundation import AVFoundation
import Observation import Combine
import SwiftUI import SwiftUI
import UIKit import UIKit
@MainActor @MainActor
@Observable
/// ///
final class ScenicQueueRuntime { final class ScenicQueueRuntime: ObservableObject {
private(set) var isMonitoring = false @Published private(set) var isMonitoring = false
private(set) var lastPollText = "--" @Published private(set) var lastPollText = "--"
private(set) var lastSpokenText = "" @Published private(set) var lastSpokenText = ""
private(set) var lastQueueCount = 0 @Published private(set) var lastQueueCount = 0
private(set) var lastError: String? @Published private(set) var lastError: String?
@ObservationIgnored private let speaker = ScenicQueueSpeechService() private let speaker = ScenicQueueSpeechService()
@ObservationIgnored private weak var api: (any ScenicQueueServing)? private weak var api: (any ScenicQueueServing)?
@ObservationIgnored private var userId: String? @Published private var userId: String?
@ObservationIgnored private var scenicId: Int? @Published private var scenicId: Int?
@ObservationIgnored private var scenePhase: ScenePhase = .active @Published private var scenePhase: ScenePhase = .active
@ObservationIgnored private var pollTask: Task<Void, Never>? @Published private var pollTask: Task<Void, Never>?
@ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid @Published private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
@ObservationIgnored private var announcementState = ScenicQueueAnnouncementState() @Published private var announcementState = ScenicQueueAnnouncementState()
@ObservationIgnored private var lastScenicId: Int? @Published private var lastScenicId: Int?
@ObservationIgnored private var lastSpotId: Int? @Published private var lastSpotId: Int?
@ObservationIgnored private var suspendedByQueueScreen = false @Published private var suspendedByQueueScreen = false
/// ///
var voiceEnabled: Bool { var voiceEnabled: Bool {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
struct AccountSnapshot: Codable, Equatable { struct AccountSnapshot: Codable, Equatable {
@ -41,13 +41,12 @@ struct AccountSnapshot: Codable, Equatable {
} }
} }
@Observable
/// 使 UserDefaults /// 使 UserDefaults
final class AccountSnapshotStore { final class AccountSnapshotStore {
@ObservationIgnored private let defaults: UserDefaults private let defaults: UserDefaults
@ObservationIgnored private let key: String private let key: String
@ObservationIgnored private let encoder: JSONEncoder private let encoder: JSONEncoder
@ObservationIgnored private let decoder: JSONDecoder private let decoder: JSONDecoder
/// UserDefaults /// UserDefaults
init( init(

View File

@ -6,14 +6,13 @@
// //
import Foundation import Foundation
import Observation import Combine
@Observable
/// App /// App
final class AppPreferencesStore { final class AppPreferencesStore {
@ObservationIgnored private let defaults: UserDefaults private let defaults: UserDefaults
@ObservationIgnored private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username" private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
@ObservationIgnored private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted" private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
/// UserDefaults /// UserDefaults
init(defaults: UserDefaults = .standard) { init(defaults: UserDefaults = .standard) {
@ -43,4 +42,10 @@ final class AppPreferencesStore {
func loadPrivacyAgreementAccepted() -> Bool { func loadPrivacyAgreementAccepted() -> Bool {
defaults.bool(forKey: privacyAgreementAcceptedKey) defaults.bool(forKey: privacyAgreementAcceptedKey)
} }
///
func clear() {
defaults.removeObject(forKey: lastLoginUsernameKey)
defaults.removeObject(forKey: privacyAgreementAcceptedKey)
}
} }

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
import Security import Security
/// token Keychain /// token Keychain
@ -21,11 +21,10 @@ enum SessionTokenStoreError: LocalizedError {
} }
} }
@Observable
/// token Keychain API /// token Keychain API
final class SessionTokenStore { final class SessionTokenStore {
@ObservationIgnored private let service: String private let service: String
@ObservationIgnored private let account: String private let account: String
/// token App Bundle Keychain /// token App Bundle Keychain
init( init(

View File

@ -40,7 +40,7 @@ struct RemoteImage<Placeholder: View>: View {
placeholder() placeholder()
} }
} }
.onChange(of: normalizedURLString) { _, _ in .onChange(of: normalizedURLString) { _ in
didFail = false didFail = false
} }
} }

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
import UniformTypeIdentifiers import UniformTypeIdentifiers
#if canImport(AlibabaCloudOSS) #if canImport(AlibabaCloudOSS)
@ -51,10 +51,9 @@ protocol OSSUploadServing {
} }
@MainActor @MainActor
@Observable
/// OSS STS SDK URL /// OSS STS SDK URL
final class OSSUploadService { final class OSSUploadService {
@ObservationIgnored private let configService: any OSSConfigServing private let configService: any OSSConfigServing
/// OSS STS /// OSS STS
init(configService: any OSSConfigServing) { init(configService: any OSSConfigServing) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// OSS STS token 便 /// OSS STS token 便
@MainActor @MainActor
@ -16,10 +16,9 @@ protocol OSSConfigServing {
} }
@MainActor @MainActor
@Observable
/// API /// API
final class UploadAPI { final class UploadAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
/// 便 /// 便
@ -25,10 +25,9 @@ protocol AccountContextServing {
} }
@MainActor @MainActor
@Observable
/// API /// API
final class AccountContextAPI: AccountContextServing { final class AccountContextAPI: AccountContextServing {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// 便 ViewModel /// 便 ViewModel
@MainActor @MainActor
@ -83,9 +83,8 @@ protocol AssetsServing {
/// API /// API
@MainActor @MainActor
@Observable
final class AssetsAPI: AssetsServing { final class AssetsAPI: AssetsServing {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,15 +6,14 @@
// //
import Foundation import Foundation
import Observation import Combine
/// App /// App
@MainActor @MainActor
@Observable final class CloudTransferStore: ObservableObject {
final class CloudTransferStore {
static let shared = CloudTransferStore() static let shared = CloudTransferStore()
private(set) var records: [CloudTransferItem] = [] @Published private(set) var records: [CloudTransferItem] = []
/// ID /// ID
@discardableResult @discardableResult
@ -56,19 +55,18 @@ final class CloudTransferStore {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class CloudStorageViewModel: ObservableObject {
final class CloudStorageViewModel { @Published var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] @Published var files: [CloudDriveFile] = []
var files: [CloudDriveFile] = [] @Published var total = 0
var total = 0 @Published var page = 1
var page = 1 @Published var searchText = ""
var searchText = "" @Published var selectedFilter: CloudDriveFilter = .all
var selectedFilter: CloudDriveFilter = .all @Published var selectedSort: CloudDriveSort = .updatedDesc
var selectedSort: CloudDriveSort = .updatedDesc @Published var isLoading = false
var isLoading = false @Published var isLoadingMore = false
var isLoadingMore = false @Published var isMutating = false
var isMutating = false @Published var errorMessage: String?
var errorMessage: String?
private let pageSize = 20 private let pageSize = 20
@ -260,20 +258,19 @@ final class CloudStorageViewModel {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class MediaLibraryViewModel: ObservableObject {
final class MediaLibraryViewModel {
let kind: MediaLibraryKind let kind: MediaLibraryKind
var items: [MediaLibraryItem] = [] @Published var items: [MediaLibraryItem] = []
var selectedDetail: MediaLibraryDetail? @Published var selectedDetail: MediaLibraryDetail?
var orderInfo: MediaLibraryOrderInfo? @Published var orderInfo: MediaLibraryOrderInfo?
var total = 0 @Published var total = 0
var page = 1 @Published var page = 1
var keyword = "" @Published var keyword = ""
var selectedAuditFilter: MediaLibraryAuditFilter = .all @Published var selectedAuditFilter: MediaLibraryAuditFilter = .all
var isLoading = false @Published var isLoading = false
var isLoadingMore = false @Published var isLoadingMore = false
var isLoadingDetail = false @Published var isLoadingDetail = false
var errorMessage: String? @Published var errorMessage: String?
private let pageSize = 10 private let pageSize = 10
@ -396,20 +393,19 @@ final class MediaLibraryViewModel {
/// ViewModelOSS /// ViewModelOSS
@MainActor @MainActor
@Observable final class MediaLibraryEditorViewModel: ObservableObject {
final class MediaLibraryEditorViewModel { @Published var name = ""
var name = "" @Published var description = ""
var description = "" @Published var selectedSpotId: Int?
var selectedSpotId: Int? @Published var selectedProjectId: Int?
var selectedProjectId: Int? @Published var projects: [PhotographerProjectItem] = []
var projects: [PhotographerProjectItem] = [] @Published var tagsText = ""
var tagsText = "" @Published var coverFile: MediaLocalUploadFile?
var coverFile: MediaLocalUploadFile? @Published var mediaFiles: [MediaLocalUploadFile] = []
var mediaFiles: [MediaLocalUploadFile] = [] @Published var isSubmitting = false
var isSubmitting = false @Published var isLoadingProjects = false
var isLoadingProjects = false @Published var errorMessage: String?
var errorMessage: String? @Published var didSubmitSuccessfully = false
var didSubmitSuccessfully = false
private let editingId: Int? private let editingId: Int?
@ -611,18 +607,17 @@ final class MediaLibraryEditorViewModel {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class AlbumListViewModel: ObservableObject {
final class AlbumListViewModel { @Published var folders: [AlbumFolderItem] = []
var folders: [AlbumFolderItem] = [] @Published var total = 0
var total = 0 @Published var page = 1
var page = 1 @Published var searchText = ""
var searchText = "" @Published var startTime = ""
var startTime = "" @Published var endTime = ""
var endTime = "" @Published var isLoading = false
var isLoading = false @Published var isLoadingMore = false
var isLoadingMore = false @Published var isMutating = false
var isMutating = false @Published var errorMessage: String?
var errorMessage: String?
private let pageSize = 10 private let pageSize = 10
@ -716,18 +711,17 @@ final class AlbumListViewModel {
/// ViewModel/ /// ViewModel/
@MainActor @MainActor
@Observable final class AlbumDetailViewModel: ObservableObject {
final class AlbumDetailViewModel {
let folderId: Int let folderId: Int
var folder: AlbumFolderItem? @Published var folder: AlbumFolderItem?
var files: [AlbumFileItem] = [] @Published var files: [AlbumFileItem] = []
var selectedTab: AlbumFileTab = .image @Published var selectedTab: AlbumFileTab = .image
var total = 0 @Published var total = 0
var page = 1 @Published var page = 1
var isLoading = false @Published var isLoading = false
var isLoadingMore = false @Published var isLoadingMore = false
var isMutating = false @Published var isMutating = false
var errorMessage: String? @Published var errorMessage: String?
private let pageSize = 20 private let pageSize = 20
@ -863,16 +857,15 @@ final class AlbumDetailViewModel {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class AlbumTrailerViewModel: ObservableObject {
final class AlbumTrailerViewModel { @Published var folders: [AlbumFolderItem] = []
var folders: [AlbumFolderItem] = [] @Published var selectedFolderId: Int?
var selectedFolderId: Int? @Published var localFiles: [AlbumLocalUploadFile] = []
var localFiles: [AlbumLocalUploadFile] = [] @Published var uploadProgress = 0
var uploadProgress = 0 @Published var isLoadingFolders = false
var isLoadingFolders = false @Published var isSubmitting = false
var isSubmitting = false @Published var errorMessage: String?
var errorMessage: String? @Published var didUploadSuccessfully = false
var didUploadSuccessfully = false
/// ///
func loadFolders(api: any AssetsServing, scenicId: Int?) async { func loadFolders(api: any AssetsServing, scenicId: Int?) async {

View File

@ -12,12 +12,12 @@ import UniformTypeIdentifiers
/// ///
struct AlbumListView: View { struct AlbumListView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AssetsAPI.self) private var assetsAPI @Environment(\.assetsAPI) private var assetsAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = AlbumListViewModel() @StateObject private var viewModel = AlbumListViewModel()
@State private var showCreateSheet = false @State private var showCreateSheet = false
@State private var newAlbumName = "" @State private var newAlbumName = ""
@State private var newAlbumRemark = "" @State private var newAlbumRemark = ""
@ -158,12 +158,12 @@ struct AlbumListView: View {
/// / /// /
struct AlbumDetailView: View { struct AlbumDetailView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AssetsAPI.self) private var assetsAPI @Environment(\.assetsAPI) private var assetsAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel: AlbumDetailViewModel @StateObject private var viewModel: AlbumDetailViewModel
@State private var actionFile: AlbumFileItem? @State private var actionFile: AlbumFileItem?
@State private var previewFile: AlbumFileItem? @State private var previewFile: AlbumFileItem?
@State private var showRenameSheet = false @State private var showRenameSheet = false
@ -174,7 +174,7 @@ struct AlbumDetailView: View {
/// ///
init(folderId: Int, summary: AlbumFolderItem? = nil) { 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 { var body: some View {
@ -282,7 +282,7 @@ struct AlbumDetailView: View {
} }
} }
.pickerStyle(.segmented) .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) } Task { await viewModel.selectTab(tab, api: assetsAPI, scenicId: accountContext.currentScenic?.id) }
} }
} }
@ -378,14 +378,14 @@ struct AlbumTrailerEntryView: View {
/// ///
struct AlbumTrailerUploadView: View { struct AlbumTrailerUploadView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AssetsAPI.self) private var assetsAPI @Environment(\.assetsAPI) private var assetsAPI
@Environment(OSSUploadService.self) private var uploadService @Environment(\.ossUploadService) private var uploadService
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = AlbumTrailerViewModel() @StateObject private var viewModel = AlbumTrailerViewModel()
@State private var selectedItems: [PhotosPickerItem] = [] @State private var selectedItems: [PhotosPickerItem] = []
let initialFolderId: Int? let initialFolderId: Int?
@ -412,7 +412,7 @@ struct AlbumTrailerUploadView: View {
toastCenter.show(message) toastCenter.show(message)
} }
} }
.onChange(of: selectedItems) { _, items in .onChange(of: selectedItems) { items in
Task { await loadPickedFiles(items) } Task { await loadPickedFiles(items) }
} }
} }

View File

@ -14,14 +14,14 @@ import UIKit
/// ///
struct CloudStorageView: View { struct CloudStorageView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AssetsAPI.self) private var assetsAPI @Environment(\.assetsAPI) private var assetsAPI
@Environment(OSSUploadService.self) private var uploadService @Environment(\.ossUploadService) private var uploadService
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = CloudStorageViewModel() @StateObject private var viewModel = CloudStorageViewModel()
@State private var transferStore = CloudTransferStore.shared @State private var transferStore = CloudTransferStore.shared
@State private var selectedItems: [PhotosPickerItem] = [] @State private var selectedItems: [PhotosPickerItem] = []
@State private var actionItem: CloudDriveFile? @State private var actionItem: CloudDriveFile?
@ -70,7 +70,7 @@ struct CloudStorageView: View {
guard viewModel.files.isEmpty else { return } guard viewModel.files.isEmpty else { return }
await reload(showLoading: true) await reload(showLoading: true)
} }
.onChange(of: selectedItems) { _, items in .onChange(of: selectedItems) { items in
Task { await upload(items) } Task { await upload(items) }
} }
.confirmationDialog("文件操作", isPresented: actionDialogBinding, presenting: actionItem) { item in .confirmationDialog("文件操作", isPresented: actionDialogBinding, presenting: actionItem) { item in
@ -176,7 +176,7 @@ struct CloudStorageView: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: viewModel.selectedFilter) { _, _ in .onChange(of: viewModel.selectedFilter) { _ in
Task { await reload(showLoading: true) } Task { await reload(showLoading: true) }
} }
@ -186,7 +186,7 @@ struct CloudStorageView: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: viewModel.selectedSort) { _, _ in .onChange(of: viewModel.selectedSort) { _ in
Task { await reload(showLoading: true) } Task { await reload(showLoading: true) }
} }
} }

View File

@ -12,18 +12,18 @@ import SwiftUI
struct MediaLibraryView: View { struct MediaLibraryView: View {
let kind: MediaLibraryKind let kind: MediaLibraryKind
@Environment(AssetsAPI.self) private var assetsAPI @Environment(\.assetsAPI) private var assetsAPI
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel: MediaLibraryViewModel @StateObject private var viewModel: MediaLibraryViewModel
@State private var selectedItem: MediaLibraryItem? @State private var selectedItem: MediaLibraryItem?
/// ///
init(kind: MediaLibraryKind = .material) { init(kind: MediaLibraryKind = .material) {
self.kind = kind self.kind = kind
_viewModel = State(initialValue: MediaLibraryViewModel(kind: kind)) _viewModel = StateObject(wrappedValue: MediaLibraryViewModel(kind: kind))
} }
var body: some View { var body: some View {
@ -54,7 +54,7 @@ struct MediaLibraryView: View {
guard viewModel.items.isEmpty else { return } guard viewModel.items.isEmpty else { return }
await reload(showLoading: true) await reload(showLoading: true)
} }
.navigationDestination(item: $selectedItem) { item in .appNavigationDestination(item: $selectedItem) { item in
MediaLibraryDetailView(item: item, listViewModel: viewModel) MediaLibraryDetailView(item: item, listViewModel: viewModel)
} }
} }
@ -82,7 +82,7 @@ struct MediaLibraryView: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: viewModel.selectedAuditFilter) { _, _ in .onChange(of: viewModel.selectedAuditFilter) { _ in
Task { await reload(showLoading: true) } Task { await reload(showLoading: true) }
} }
} }
@ -235,8 +235,8 @@ struct MediaLibraryDetailView: View {
let item: MediaLibraryItem let item: MediaLibraryItem
let listViewModel: MediaLibraryViewModel let listViewModel: MediaLibraryViewModel
@Environment(AssetsAPI.self) private var assetsAPI @Environment(\.assetsAPI) private var assetsAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@ -415,15 +415,15 @@ struct MediaLibraryUploadView: View {
let kind: MediaLibraryKind let kind: MediaLibraryKind
let editingDetail: MediaLibraryDetail? let editingDetail: MediaLibraryDetail?
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext
@Environment(AssetsAPI.self) private var assetsAPI @Environment(\.assetsAPI) private var assetsAPI
@Environment(OSSUploadService.self) private var uploadService @Environment(\.ossUploadService) private var uploadService
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel: MediaLibraryEditorViewModel @StateObject private var viewModel: MediaLibraryEditorViewModel
@State private var coverSelection: PhotosPickerItem? @State private var coverSelection: PhotosPickerItem?
@State private var mediaSelection: [PhotosPickerItem] = [] @State private var mediaSelection: [PhotosPickerItem] = []
@ -431,7 +431,7 @@ struct MediaLibraryUploadView: View {
init(kind: MediaLibraryKind = .material, editingDetail: MediaLibraryDetail? = nil) { init(kind: MediaLibraryKind = .material, editingDetail: MediaLibraryDetail? = nil) {
self.kind = kind self.kind = kind
self.editingDetail = editingDetail self.editingDetail = editingDetail
_viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail)) _viewModel = StateObject(wrappedValue: MediaLibraryEditorViewModel(detail: editingDetail))
} }
var body: some View { var body: some View {
@ -457,10 +457,10 @@ struct MediaLibraryUploadView: View {
toastCenter.show(message) toastCenter.show(message)
} }
} }
.onChange(of: coverSelection) { _, item in .onChange(of: coverSelection) { item in
Task { await loadCover(item) } Task { await loadCover(item) }
} }
.onChange(of: mediaSelection) { _, items in .onChange(of: mediaSelection) { items in
Task { await loadMedia(items) } Task { await loadMedia(items) }
} }
} }

View File

@ -6,13 +6,12 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// API v9 /// API v9
final class AuthAPI { final class AuthAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
enum LoginField: Hashable { enum LoginField: Hashable {
@ -68,17 +68,16 @@ enum LoginFlowError: LocalizedError {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class LoginViewModel { final class LoginViewModel: ObservableObject {
var username = "18651857230" @Published var username = "18651857230"
var password = "zhifly666" @Published var password = "zhifly666"
var privacyChecked = false @Published var privacyChecked = false
var isLoading = false @Published var isLoading = false
var isSelectingAccount = false @Published var isSelectingAccount = false
var showsPassword = false @Published var showsPassword = false
var showsAgreementSheet = false @Published var showsAgreementSheet = false
var pendingAccountSelection: AccountSelectionPayload? @Published var pendingAccountSelection: AccountSelectionPayload?
var canSubmit: Bool { var canSubmit: Bool {
isValidPhone && !trimmedPassword.isEmpty isValidPhone && !trimmedPassword.isEmpty

View File

@ -44,7 +44,7 @@ struct AccountSelectionView: View {
@ViewBuilder @ViewBuilder
private var accountList: some View { private var accountList: some View {
if payload.accounts.isEmpty { if payload.accounts.isEmpty {
ContentUnavailableView( AppContentUnavailableView(
"暂无可用账号", "暂无可用账号",
systemImage: "person.crop.circle.badge.exclamationmark", systemImage: "person.crop.circle.badge.exclamationmark",
description: Text("当前账号没有可用的景区账号或门店账号,请联系管理员。") description: Text("当前账号没有可用的景区账号或门店账号,请联系管理员。")

View File

@ -9,17 +9,17 @@ import SwiftUI
/// ///
struct LoginView: View { struct LoginView: View {
@Environment(AppSession.self) private var appSession @EnvironmentObject private var appSession: AppSession
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(PermissionContext.self) private var permissionContext @EnvironmentObject private var permissionContext: PermissionContext
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(AuthAPI.self) private var authAPI @Environment(\.authAPI) private var authAPI
@Environment(ProfileAPI.self) private var profileAPI @Environment(\.profileAPI) private var profileAPI
@Environment(AccountContextAPI.self) private var accountContextAPI @Environment(\.accountContextAPI) private var accountContextAPI
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator @Environment(\.authSessionCoordinator) private var authSessionCoordinator
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = LoginViewModel() @StateObject private var viewModel = LoginViewModel()
@FocusState private var focusedField: LoginField? @FocusState private var focusedField: LoginField?
private var contentMaxWidth: CGFloat { private var contentMaxWidth: CGFloat {
@ -76,11 +76,11 @@ struct LoginView: View {
) )
} }
} }
.onChange(of: viewModel.username) { _, _ in .onChange(of: viewModel.username) { _ in
viewModel.normalizeUsernameCountryCodeIfNeeded() viewModel.normalizeUsernameCountryCodeIfNeeded()
dismissLoginToastIfNeeded() dismissLoginToastIfNeeded()
} }
.onChange(of: viewModel.password) { _, _ in .onChange(of: viewModel.password) { _ in
dismissLoginToastIfNeeded() dismissLoginToastIfNeeded()
} }
.task { .task {
@ -462,12 +462,12 @@ private struct LoginAgreementConsentSheet: View {
#Preview { #Preview {
LoginView() LoginView()
.environment(AppSession()) .environmentObject(AppSession())
.environment(AccountContext()) .environmentObject(AccountContext())
.environment(PermissionContext()) .environmentObject(PermissionContext())
.environment(ToastCenter()) .environmentObject(ToastCenter())
.environment(AuthAPI(client: APIClient())) .environment(\.authAPI, AuthAPI(client: APIClient()))
.environment(ProfileAPI(client: APIClient())) .environment(\.profileAPI, ProfileAPI(client: APIClient()))
.environment(AccountContextAPI(client: APIClient())) .environment(\.accountContextAPI, AccountContextAPI(client: APIClient()))
.environment(AuthSessionCoordinator()) .environment(\.authSessionCoordinator, AuthSessionCoordinator())
} }

View File

@ -6,13 +6,12 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class HomeViewModel { final class HomeViewModel: ObservableObject {
private(set) var menuItems: [HomeMenuItem] = [] @Published private(set) var menuItems: [HomeMenuItem] = []
/// Android /// Android
private let preferredOrder: [String] = [ private let preferredOrder: [String] = [

View File

@ -9,12 +9,12 @@ import SwiftUI
/// ///
struct HomeMoreFunctionsView: View { struct HomeMoreFunctionsView: View {
@Environment(PermissionContext.self) private var permissionContext @EnvironmentObject private var permissionContext: PermissionContext
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var viewModel = HomeViewModel() @StateObject private var viewModel = HomeViewModel()
@State private var commonUris: [String] = [] @State private var commonUris: [String] = []
private let commonMenuStore = HomeCommonMenuStore() private let commonMenuStore = HomeCommonMenuStore()
@ -49,10 +49,10 @@ struct HomeMoreFunctionsView: View {
.task { .task {
rebuildMenus() rebuildMenus()
} }
.onChange(of: permissionContext.currentRole?.id) { _, _ in .onChange(of: permissionContext.currentRole?.id) { _ in
rebuildMenus() rebuildMenus()
} }
.onChange(of: permissionContext.rolePermissions.count) { _, _ in .onChange(of: permissionContext.rolePermissions.count) { _ in
rebuildMenus() rebuildMenus()
} }
} }

View File

@ -10,13 +10,13 @@ import SwiftUI
/// ///
struct HomeView: View { struct HomeView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(PermissionContext.self) private var permissionContext @EnvironmentObject private var permissionContext: PermissionContext
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = HomeViewModel() @StateObject private var viewModel = HomeViewModel()
@State private var commonUris: [String] = [] @State private var commonUris: [String] = []
@State private var isOnline = false @State private var isOnline = false
@State private var reminderMinutes = 0 @State private var reminderMinutes = 0
@ -113,10 +113,10 @@ struct HomeView: View {
guard isOnline, secondsUntilReport > 0 else { return } guard isOnline, secondsUntilReport > 0 else { return }
secondsUntilReport -= 1 secondsUntilReport -= 1
} }
.onChange(of: permissionContext.currentRole?.id) { _, _ in .onChange(of: permissionContext.currentRole?.id) { _ in
rebuildMenusFromCurrentContext() rebuildMenusFromCurrentContext()
} }
.onChange(of: permissionContext.rolePermissions.count) { _, _ in .onChange(of: permissionContext.rolePermissions.count) { _ in
rebuildMenusFromCurrentContext() rebuildMenusFromCurrentContext()
} }
.alert("切换在线状态", isPresented: $showOnlineDialog) { .alert("切换在线状态", isPresented: $showOnlineDialog) {
@ -423,9 +423,9 @@ struct HomeView: View {
#Preview { #Preview {
NavigationStack { NavigationStack {
HomeView() HomeView()
.environment(AccountContext()) .environmentObject(AccountContext())
.environment(PermissionContext()) .environmentObject(PermissionContext())
.environment(AppRouter()) .environmentObject(AppRouter())
.environment(RouterPath()) .environmentObject(RouterPath())
} }
} }

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
@MainActor @MainActor
@ -20,9 +20,8 @@ protocol InviteServing {
/// API /// API
@MainActor @MainActor
@Observable
final class InviteAPI: InviteServing { final class InviteAPI: InviteServing {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -7,22 +7,21 @@
import CoreImage.CIFilterBuiltins import CoreImage.CIFilterBuiltins
import Foundation import Foundation
import Observation import Combine
import UIKit import UIKit
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class PhotographerInviteViewModel: ObservableObject {
final class PhotographerInviteViewModel { @Published private(set) var loading = false
private(set) var loading = false @Published private(set) var inviteCode = ""
private(set) var inviteCode = "" @Published private(set) var inviteUrl = ""
private(set) var inviteUrl = "" @Published private(set) var rules: [String] = []
private(set) var rules: [String] = [] @Published private(set) var qrImage: UIImage?
private(set) var qrImage: UIImage? @Published var errorMessage: String?
var errorMessage: String?
@ObservationIgnored private let qrContext = CIContext() private let qrContext = CIContext()
@ObservationIgnored private let qrFilter = CIFilter.qrCodeGenerator() private let qrFilter = CIFilter.qrCodeGenerator()
/// ///
func reload(api: any InviteServing) async { func reload(api: any InviteServing) async {
@ -72,21 +71,20 @@ final class PhotographerInviteViewModel {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class InviteRecordViewModel: ObservableObject {
final class InviteRecordViewModel { @Published var tab: InviteRecordTab = .invite
var tab: InviteRecordTab = .invite @Published private(set) var totalRewardText = "¥ 0.00"
private(set) var totalRewardText = "¥ 0.00" @Published private(set) var withdrawableText = "¥ 0.00"
private(set) var withdrawableText = "¥ 0.00" @Published private(set) var displayRows: [InviteDisplayRow] = []
private(set) var displayRows: [InviteDisplayRow] = [] @Published private(set) var loading = false
private(set) var loading = false @Published private(set) var loadingMore = false
private(set) var loadingMore = false @Published private(set) var hasMore = false
private(set) var hasMore = false @Published var errorMessage: String?
var errorMessage: String?
private var inviteRows: [InviteDisplayRow] = [] @Published private var inviteRows: [InviteDisplayRow] = []
private var rewardRows: [InviteDisplayRow] = [] @Published private var rewardRows: [InviteDisplayRow] = []
private var invitePage = 1 @Published private var invitePage = 1
private var rewardPage = 1 @Published private var rewardPage = 1
private let invitePageSize = 20 private let invitePageSize = 20
private let rewardPageSize = 10 private let rewardPageSize = 10

View File

@ -9,10 +9,10 @@ import SwiftUI
/// ///
struct PhotographerInviteView: View { struct PhotographerInviteView: View {
@Environment(InviteAPI.self) private var inviteAPI @Environment(\.inviteAPI) private var inviteAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = PhotographerInviteViewModel() @StateObject private var viewModel = PhotographerInviteViewModel()
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -140,10 +140,10 @@ struct PhotographerInviteView: View {
/// ///
struct InviteRecordView: View { struct InviteRecordView: View {
@Environment(InviteAPI.self) private var inviteAPI @Environment(\.inviteAPI) private var inviteAPI
@Environment(WalletAPI.self) private var walletAPI @Environment(\.walletAPI) private var walletAPI
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = InviteRecordViewModel() @StateObject private var viewModel = InviteRecordViewModel()
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -155,7 +155,7 @@ struct InviteRecordView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.padding(AppMetrics.Spacing.medium) .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) } Task { await viewModel.selectTab(newValue, inviteAPI: inviteAPI, walletAPI: walletAPI) }
} }
recordList recordList
@ -205,7 +205,7 @@ struct InviteRecordView: View {
ScrollView { ScrollView {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
if viewModel.displayRows.isEmpty && !viewModel.loading { if viewModel.displayRows.isEmpty && !viewModel.loading {
ContentUnavailableView("暂无记录", systemImage: "tray") AppContentUnavailableView("暂无记录", systemImage: "tray")
.frame(minHeight: 280) .frame(minHeight: 280)
} }
ForEach(viewModel.displayRows) { row in ForEach(viewModel.displayRows) { row in

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
@MainActor @MainActor
@ -26,10 +26,9 @@ protocol LiveServing {
} }
@MainActor @MainActor
@Observable
/// API /// API
final class LiveAPI { final class LiveAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -7,7 +7,7 @@
import AVFoundation import AVFoundation
import Foundation import Foundation
import Observation import Combine
/// RTMP /// RTMP
enum LivePlaybackURLResolver { enum LivePlaybackURLResolver {
@ -44,13 +44,12 @@ enum LivePlaybackState: Equatable {
} }
@MainActor @MainActor
@Observable
/// ViewModel URL /// ViewModel URL
final class LivePlaybackViewModel { final class LivePlaybackViewModel: ObservableObject {
var state: LivePlaybackState = .empty @Published var state: LivePlaybackState = .empty
var errorMessage: String? @Published var errorMessage: String?
@ObservationIgnored private(set) var player: AVPlayer? @Published private(set) var player: AVPlayer?
var playableURL: URL? { var playableURL: URL? {
switch state { switch state {

View File

@ -8,7 +8,7 @@
import AVFoundation import AVFoundation
import Foundation import Foundation
import Network import Network
import Observation import Combine
/// ///
enum LivePushPermissionState: Equatable { enum LivePushPermissionState: Equatable {
@ -158,23 +158,22 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
} }
@MainActor @MainActor
@Observable
/// ViewModel SDK /// ViewModel SDK
final class LivePushReadinessViewModel { final class LivePushReadinessViewModel: ObservableObject {
var cameraPermission: LivePushPermissionState = .unknown @Published var cameraPermission: LivePushPermissionState = .unknown
var microphonePermission: LivePushPermissionState = .unknown @Published var microphonePermission: LivePushPermissionState = .unknown
var networkState: LivePushNetworkState = .unknown @Published var networkState: LivePushNetworkState = .unknown
var prepared = false @Published var prepared = false
var running = false @Published var running = false
var errorMessage: String? @Published var errorMessage: String?
let adapterName: String let adapterName: String
let sdkStatusText: String let sdkStatusText: String
@ObservationIgnored private let permissionProvider: any LivePermissionProviding private let permissionProvider: any LivePermissionProviding
@ObservationIgnored private let networkMonitor: any LiveNetworkMonitoring private let networkMonitor: any LiveNetworkMonitoring
@ObservationIgnored private let adapter: any LivePushAdapter private let adapter: any LivePushAdapter
@ObservationIgnored private var pushURL: URL? @Published private var pushURL: URL?
init( init(
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(), permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),

View File

@ -6,22 +6,21 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class LiveManagementViewModel { final class LiveManagementViewModel: ObservableObject {
var items: [LiveEntity] = [] @Published var items: [LiveEntity] = []
var detail: LiveEntity? @Published var detail: LiveEntity?
var loading = false @Published var loading = false
var loadingMore = false @Published var loadingMore = false
var hasMore = false @Published var hasMore = false
var page = 1 @Published var page = 1
var errorMessage: String? @Published var errorMessage: String?
@ObservationIgnored private let pageSize = 10 private let pageSize = 10
@ObservationIgnored private var total = 0 @Published private var total = 0
/// ///
var liveRunningCount: Int { var liveRunningCount: Int {
@ -136,13 +135,12 @@ final class LiveManagementViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class LiveDetailViewModel { final class LiveDetailViewModel: ObservableObject {
var detail: LiveEntity @Published var detail: LiveEntity
var loading = false @Published var loading = false
var actionInFlight = false @Published var actionInFlight = false
var errorMessage: String? @Published var errorMessage: String?
init(detail: LiveEntity) { init(detail: LiveEntity) {
self.detail = detail self.detail = detail
@ -192,20 +190,19 @@ final class LiveDetailViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class LiveAlbumViewModel { final class LiveAlbumViewModel: ObservableObject {
var folders: [LiveAlbumFolderItem] = [] @Published var folders: [LiveAlbumFolderItem] = []
var startDate: Date? @Published var startDate: Date?
var endDate: Date? @Published var endDate: Date?
var loading = false @Published var loading = false
var loadingMore = false @Published var loadingMore = false
var hasMore = false @Published var hasMore = false
var page = 1 @Published var page = 1
var errorMessage: String? @Published var errorMessage: String?
@ObservationIgnored private let pageSize = 10 private let pageSize = 10
@ObservationIgnored private var total = 0 @Published private var total = 0
/// ///
func setStartDate(_ date: Date) throws { func setStartDate(_ date: Date) throws {
@ -301,14 +298,13 @@ final class LiveAlbumViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class LiveAlbumCreateViewModel { final class LiveAlbumCreateViewModel: ObservableObject {
var name = "" @Published var name = ""
var localFiles: [LiveAlbumLocalUploadFile] = [] @Published var localFiles: [LiveAlbumLocalUploadFile] = []
var submitting = false @Published var submitting = false
var uploadProgress = 0 @Published var uploadProgress = 0
var errorMessage: String? @Published var errorMessage: String?
/// ///
func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) { func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) {
@ -368,15 +364,14 @@ final class LiveAlbumCreateViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class LiveAlbumPreviewViewModel { final class LiveAlbumPreviewViewModel: ObservableObject {
let folderId: Int let folderId: Int
var folder: LiveAlbumFolderItem? @Published var folder: LiveAlbumFolderItem?
var files: [LiveAlbumFileItem] = [] @Published var files: [LiveAlbumFileItem] = []
var currentIndex: Int @Published var currentIndex: Int
var loading = false @Published var loading = false
var errorMessage: String? @Published var errorMessage: String?
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) { init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
self.folderId = folderId self.folderId = folderId

View File

@ -12,12 +12,12 @@ import UniformTypeIdentifiers
/// ///
struct LiveAlbumView: View { struct LiveAlbumView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(LiveAPI.self) private var liveAPI @Environment(\.liveAPI) private var liveAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = LiveAlbumViewModel() @StateObject private var viewModel = LiveAlbumViewModel()
@State private var showStartPicker = false @State private var showStartPicker = false
@State private var showEndPicker = false @State private var showEndPicker = false
@State private var showCreatePage = false @State private var showCreatePage = false
@ -29,7 +29,7 @@ struct LiveAlbumView: View {
ScrollView { ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.medium) { LazyVStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.folders.isEmpty { if viewModel.folders.isEmpty {
ContentUnavailableView("暂无素材", systemImage: "photo.stack") AppContentUnavailableView("暂无素材", systemImage: "photo.stack")
.frame(maxWidth: .infinity, minHeight: 300) .frame(maxWidth: .infinity, minHeight: 300)
} else { } else {
ForEach(viewModel.folders) { folder in ForEach(viewModel.folders) { folder in
@ -286,13 +286,13 @@ private struct LiveDatePickerSheet: View {
/// / /// /
struct LiveAlbumCreateView: View { struct LiveAlbumCreateView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(LiveAPI.self) private var liveAPI @Environment(\.liveAPI) private var liveAPI
@Environment(OSSUploadService.self) private var uploadService @Environment(\.ossUploadService) private var uploadService
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var viewModel = LiveAlbumCreateViewModel() @StateObject private var viewModel = LiveAlbumCreateViewModel()
@State private var pickerItems: [PhotosPickerItem] = [] @State private var pickerItems: [PhotosPickerItem] = []
let onCreated: () -> Void let onCreated: () -> Void
@ -330,7 +330,7 @@ struct LiveAlbumCreateView: View {
.background(Color(hex: 0xF5F7FA)) .background(Color(hex: 0xF5F7FA))
.navigationTitle("上传素材") .navigationTitle("上传素材")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.onChange(of: pickerItems) { _, newItems in .onChange(of: pickerItems) { newItems in
Task { await importPickerItems(newItems) } Task { await importPickerItems(newItems) }
} }
} }
@ -338,7 +338,7 @@ struct LiveAlbumCreateView: View {
@ViewBuilder @ViewBuilder
private var localFileGrid: some View { private var localFileGrid: some View {
if viewModel.localFiles.isEmpty { if viewModel.localFiles.isEmpty {
ContentUnavailableView("未选择素材", systemImage: "photo") AppContentUnavailableView("未选择素材", systemImage: "photo")
.frame(maxWidth: .infinity, minHeight: 180) .frame(maxWidth: .infinity, minHeight: 180)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
} else { } else {
@ -392,21 +392,21 @@ struct LiveAlbumCreateView: View {
/// ///
struct LiveAlbumPreviewView: View { struct LiveAlbumPreviewView: View {
@Environment(LiveAPI.self) private var liveAPI @Environment(\.liveAPI) private var liveAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var viewModel: LiveAlbumPreviewViewModel @StateObject private var viewModel: LiveAlbumPreviewViewModel
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
init(folderId: Int, startIndex: Int, summary: LiveAlbumFolderItem?) { 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if viewModel.files.isEmpty { if viewModel.files.isEmpty {
ContentUnavailableView("没有可预览的素材", systemImage: "photo") AppContentUnavailableView("没有可预览的素材", systemImage: "photo")
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black) .background(Color.black)
} else { } else {
@ -459,12 +459,12 @@ struct LiveAlbumPreviewView: View {
private struct LiveAlbumPreviewPage: View { private struct LiveAlbumPreviewPage: View {
let file: LiveAlbumFileItem let file: LiveAlbumFileItem
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@State private var playbackViewModel: LivePlaybackViewModel @StateObject private var playbackViewModel: LivePlaybackViewModel
init(file: LiveAlbumFileItem) { init(file: LiveAlbumFileItem) {
self.file = file self.file = file
_playbackViewModel = State(initialValue: LivePlaybackViewModel(urlString: file.url)) _playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(urlString: file.url))
} }
var body: some View { var body: some View {

View File

@ -10,12 +10,12 @@ import SwiftUI
/// ///
struct LiveManagementView: View { struct LiveManagementView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(LiveAPI.self) private var liveAPI @Environment(\.liveAPI) private var liveAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = LiveManagementViewModel() @StateObject private var viewModel = LiveManagementViewModel()
@State private var showAddPage = false @State private var showAddPage = false
var body: some View { var body: some View {
@ -24,7 +24,7 @@ struct LiveManagementView: View {
ScrollView { ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.medium) { LazyVStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.items.isEmpty { if viewModel.items.isEmpty {
ContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right") AppContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right")
.frame(maxWidth: .infinity, minHeight: 280) .frame(maxWidth: .infinity, minHeight: 280)
} else { } else {
ForEach(viewModel.items) { item in ForEach(viewModel.items) { item in
@ -231,9 +231,9 @@ private struct LiveSmallAction: View {
/// ///
struct LiveAddView: View { struct LiveAddView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(LiveAPI.self) private var liveAPI @Environment(\.liveAPI) private var liveAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var title = "" @State private var title = ""
@ -351,18 +351,18 @@ struct LiveAddView: View {
/// ///
struct LiveDetailView: View { struct LiveDetailView: View {
@Environment(LiveAPI.self) private var liveAPI @Environment(\.liveAPI) private var liveAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel: LiveDetailViewModel @StateObject private var viewModel: LiveDetailViewModel
@State private var playbackViewModel: LivePlaybackViewModel @StateObject private var playbackViewModel: LivePlaybackViewModel
@State private var pushReadinessViewModel = LivePushReadinessViewModel() @StateObject private var pushReadinessViewModel = LivePushReadinessViewModel()
@State private var copied = false @State private var copied = false
let onChanged: () -> Void let onChanged: () -> Void
init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) { init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) {
_viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail)) _viewModel = StateObject(wrappedValue: LiveDetailViewModel(detail: initialDetail))
_playbackViewModel = State(initialValue: LivePlaybackViewModel(live: initialDetail)) _playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(live: initialDetail))
self.onChanged = onChanged self.onChanged = onChanged
} }

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// 便 ViewModel /// 便 ViewModel
@MainActor @MainActor
@ -20,9 +20,8 @@ protocol LocationReportServing {
/// API /// API
@MainActor @MainActor
@Observable
final class LocationReportAPI: LocationReportServing { final class LocationReportAPI: LocationReportServing {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,22 +6,21 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ViewModel线 /// ViewModel线
@MainActor @MainActor
@Observable final class LocationReportViewModel: ObservableObject {
final class LocationReportViewModel { @Published var isOnline = false
var isOnline = false @Published var reminderMinutes = 30
var reminderMinutes = 30 @Published var secondsUntilReport = 0
var secondsUntilReport = 0 @Published var currentCoordinate: LocationCoordinate?
var currentCoordinate: LocationCoordinate? @Published var markedCoordinate: LocationCoordinate?
var markedCoordinate: LocationCoordinate? @Published var currentAddress = ""
var currentAddress = "" @Published var markedAddress = ""
var markedAddress = "" @Published var lastReportText = ""
var lastReportText = "" @Published var errorMessage: String?
var errorMessage: String? @Published var isSubmitting = false
var isSubmitting = false
/// ///
var countdownText: String { var countdownText: String {
@ -130,18 +129,17 @@ final class LocationReportViewModel {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class LocationReportHistoryViewModel: ObservableObject {
final class LocationReportHistoryViewModel { @Published var selectedType: LocationReportType = .all
var selectedType: LocationReportType = .all @Published var startDate: Date?
var startDate: Date? @Published var endDate: Date?
var endDate: Date? @Published var items: [LocationReportHistoryItem] = []
var items: [LocationReportHistoryItem] = [] @Published var errorMessage: String?
var errorMessage: String? @Published var isLoading = false
var isLoading = false @Published var isLoadingMore = false
var isLoadingMore = false @Published var total = 0
var total = 0
private var page = 1 @Published private var page = 1
private let pageSize = 20 private let pageSize = 20
/// ///

View File

@ -10,14 +10,14 @@ import SwiftUI
/// 线 /// 线
struct LocationReportView: View { struct LocationReportView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AccountSnapshotStore.self) private var snapshotStore @Environment(\.accountSnapshotStore) private var snapshotStore
@Environment(LocationReportAPI.self) private var locationReportAPI @Environment(\.locationReportAPI) private var locationReportAPI
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = LocationReportViewModel() @StateObject private var viewModel = LocationReportViewModel()
@State private var locationProvider = ForegroundLocationProvider() @State private var locationProvider = ForegroundLocationProvider()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@ -242,12 +242,12 @@ struct LocationReportView: View {
/// ///
struct LocationReportHistoryView: View { struct LocationReportHistoryView: View {
@Environment(AccountSnapshotStore.self) private var snapshotStore @Environment(\.accountSnapshotStore) private var snapshotStore
@Environment(LocationReportAPI.self) private var locationReportAPI @Environment(\.locationReportAPI) private var locationReportAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = LocationReportHistoryViewModel() @StateObject private var viewModel = LocationReportHistoryViewModel()
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -277,7 +277,7 @@ struct LocationReportHistoryView: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: viewModel.selectedType) { _, _ in .onChange(of: viewModel.selectedType) { _ in
Task { await reload(showLoading: true) } Task { await reload(showLoading: true) }
} }

View File

@ -6,13 +6,12 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// Tab ViewModel Tab /// Tab ViewModel Tab
final class MainTabBadgeViewModel { final class MainTabBadgeViewModel: ObservableObject {
private(set) var pendingWriteOffCount: Int? @Published private(set) var pendingWriteOffCount: Int?
/// ///
func refreshPendingWriteOffCount(api: OrderServing, scenicId: Int?, storeId: Int?) async { func refreshPendingWriteOffCount(api: OrderServing, scenicId: Int?, storeId: Int?) async {

View File

@ -32,11 +32,9 @@ struct MainTabsView: View {
/// TabView TabBar 便 /// TabView TabBar 便
private struct SystemMainTabsView: View { private struct SystemMainTabsView: View {
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
var body: some View { var body: some View {
@Bindable var appRouter = appRouter
TabView(selection: $appRouter.selectedTab) { TabView(selection: $appRouter.selectedTab) {
ForEach(AppTab.allCases) { tab in ForEach(AppTab.allCases) { tab in
TabNavigationStackHost(tab: tab) TabNavigationStackHost(tab: tab)
@ -49,12 +47,12 @@ private struct SystemMainTabsView: View {
/// TabBar Tab NavigationStack 访 /// TabBar Tab NavigationStack 访
private struct CustomMainTabsView: View { private struct CustomMainTabsView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
@Environment(OrdersAPI.self) private var ordersAPI @Environment(\.ordersAPI) private var ordersAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@State private var badgeViewModel = MainTabBadgeViewModel() @StateObject private var badgeViewModel = MainTabBadgeViewModel()
@State private var showScanner = false @State private var showScanner = false
@State private var loadedTabs: Set<AppTab> = [.home] @State private var loadedTabs: Set<AppTab> = [.home]
@ -66,8 +64,6 @@ private struct CustomMainTabsView: View {
} }
var body: some View { var body: some View {
@Bindable var appRouter = appRouter
ZStack { ZStack {
ForEach(AppTab.allCases) { tab in ForEach(AppTab.allCases) { tab in
PreservedTabPage( PreservedTabPage(
@ -102,15 +98,15 @@ private struct CustomMainTabsView: View {
loadedTabs.insert(appRouter.selectedTab) loadedTabs.insert(appRouter.selectedTab)
await refreshOrderBadge() await refreshOrderBadge()
} }
.onChange(of: appRouter.selectedTab) { _, selectedTab in .onChange(of: appRouter.selectedTab) { selectedTab in
loadedTabs.insert(selectedTab) loadedTabs.insert(selectedTab)
guard selectedTab == .orders else { return } guard selectedTab == .orders else { return }
Task { await refreshOrderBadge() } Task { await refreshOrderBadge() }
} }
.onChange(of: accountContext.currentScenic?.id) { _, _ in .onChange(of: accountContext.currentScenic?.id) { _ in
Task { await refreshOrderBadge() } Task { await refreshOrderBadge() }
} }
.onChange(of: accountContext.currentStore?.id) { _, _ in .onChange(of: accountContext.currentStore?.id) { _ in
Task { await refreshOrderBadge() } Task { await refreshOrderBadge() }
} }
} }
@ -127,7 +123,7 @@ private struct CustomMainTabsView: View {
/// TabBar Tab TabBar /// TabBar Tab TabBar
private struct CustomTabNavigationStackHost: View { private struct CustomTabNavigationStackHost: View {
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
let tab: AppTab let tab: AppTab
@Binding var selectedTab: AppTab @Binding var selectedTab: AppTab
@ -153,13 +149,13 @@ private struct CustomTabNavigationStackHost: View {
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar) .toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
} }
} }
.environment(appRouter.router(for: tab)) .environmentObject(appRouter.router(for: tab))
} }
} }
/// Tab TabBar /// Tab TabBar
private struct TabNavigationStackHost: View { private struct TabNavigationStackHost: View {
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
let tab: AppTab let tab: AppTab
@ -172,7 +168,7 @@ private struct TabNavigationStackHost: View {
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar) .toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
} }
} }
.environment(appRouter.router(for: tab)) .environmentObject(appRouter.router(for: tab))
} }
} }
@ -197,8 +193,8 @@ private struct PreservedTabPage<Content: View>: View {
#Preview { #Preview {
MainTabsView() MainTabsView()
.environment(AppRouter()) .environmentObject(AppRouter())
.environment(AccountContext()) .environmentObject(AccountContext())
.environment(OrdersAPI(client: APIClient())) .environment(\.ordersAPI, OrdersAPI(client: APIClient()))
.environment(ToastCenter()) .environmentObject(ToastCenter())
} }

View File

@ -37,7 +37,7 @@ struct ProfileRootView: View {
/// Tab /// Tab
private struct PlaceholderTabRootView: View { private struct PlaceholderTabRootView: View {
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
let title: String let title: String
let systemImage: String let systemImage: String

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
@MainActor @MainActor
@ -22,10 +22,9 @@ protocol MessageCenterServing {
} }
@MainActor @MainActor
@Observable
/// API /// API
final class MessageCenterAPI { final class MessageCenterAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,22 +6,21 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class MessageCenterViewModel { final class MessageCenterViewModel: ObservableObject {
var messages: [MessageItem] = [] @Published var messages: [MessageItem] = []
var selectedFilter: MessageFilter = .all @Published var selectedFilter: MessageFilter = .all
var isLoading = false @Published var isLoading = false
var isLoadingMore = false @Published var isLoadingMore = false
var loadFailed = false @Published var loadFailed = false
var loadFailureReason: String? @Published var loadFailureReason: String?
var message: String? @Published var message: String?
private(set) var hasMoreMessages = false @Published private(set) var hasMoreMessages = false
private(set) var lastId = 0 @Published private(set) var lastId = 0
private let pageSize = 20 private let pageSize = 20
/// ///

View File

@ -9,9 +9,9 @@ import SwiftUI
/// ///
struct MessageCenterView: View { struct MessageCenterView: View {
@Environment(MessageCenterAPI.self) private var messageAPI @Environment(\.messageCenterAPI) private var messageAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@State private var viewModel = MessageCenterViewModel() @StateObject private var viewModel = MessageCenterViewModel()
@State private var selectedMessage: MessageItem? @State private var selectedMessage: MessageItem?
@State private var processingMessageId: String? @State private var processingMessageId: String?
@ -80,7 +80,7 @@ struct MessageCenterView: View {
ProgressView("加载中...") ProgressView("加载中...")
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.loadFailed { } else if viewModel.loadFailed {
ContentUnavailableView { AppContentUnavailableView {
Label("消息加载失败", systemImage: "exclamationmark.triangle") Label("消息加载失败", systemImage: "exclamationmark.triangle")
} description: { } description: {
Text(viewModel.loadFailureReason ?? "请稍后重试") Text(viewModel.loadFailureReason ?? "请稍后重试")
@ -92,7 +92,7 @@ struct MessageCenterView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.messages.isEmpty { } else if viewModel.messages.isEmpty {
ContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息")) AppContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息"))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
ScrollView { ScrollView {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
@MainActor @MainActor
@ -16,10 +16,9 @@ protocol OperatingAreaServing {
} }
@MainActor @MainActor
@Observable
/// API /// API
final class OperatingAreaAPI { final class OperatingAreaAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,18 +6,17 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class OperatingAreaViewModel { final class OperatingAreaViewModel: ObservableObject {
private(set) var mode: OperatingAreaEntryMode? @Published private(set) var mode: OperatingAreaEntryMode?
private(set) var loading = false @Published private(set) var loading = false
private(set) var items: [OperatingAreaItem] = [] @Published private(set) var items: [OperatingAreaItem] = []
private(set) var fenceRings: [OperatingFenceRing] = [] @Published private(set) var fenceRings: [OperatingFenceRing] = []
private(set) var blockReason: OperatingAreaBlockReason? @Published private(set) var blockReason: OperatingAreaBlockReason?
private(set) var errorMessage: String? @Published private(set) var errorMessage: String?
/// ///
func reload( func reload(

View File

@ -14,13 +14,13 @@ import MAMapKit
/// ///
struct OperatingAreaView: View { struct OperatingAreaView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(PermissionContext.self) private var permissionContext @EnvironmentObject private var permissionContext: PermissionContext
@Environment(OperatingAreaAPI.self) private var operatingAreaAPI @Environment(\.operatingAreaAPI) private var operatingAreaAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = OperatingAreaViewModel() @StateObject private var viewModel = OperatingAreaViewModel()
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -87,7 +87,7 @@ struct OperatingAreaView: View {
} }
if let reason = viewModel.blockReason { if let reason = viewModel.blockReason {
ContentUnavailableView(reason.message, systemImage: "mappin.slash") AppContentUnavailableView(reason.message, systemImage: "mappin.slash")
.frame(maxWidth: .infinity, minHeight: 260) .frame(maxWidth: .infinity, minHeight: 260)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8)) .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
/// 便 /// 便
@ -59,10 +59,9 @@ protocol OrderServing {
} }
@MainActor @MainActor
@Observable
/// API /// API
final class OrdersAPI: OrderServing { final class OrdersAPI: OrderServing {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,16 +6,15 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class OrderDetailViewModel { final class OrderDetailViewModel: ObservableObject {
private(set) var detail: StoreOrderDetailResponse? @Published private(set) var detail: StoreOrderDetailResponse?
private(set) var loading = false @Published private(set) var loading = false
private(set) var errorMessage: String? @Published private(set) var errorMessage: String?
private(set) var contextMessage: String? @Published private(set) var contextMessage: String?
/// 使 /// 使
var display: StoreOrderDetailDisplay { var display: StoreOrderDetailDisplay {

View File

@ -6,20 +6,19 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel退 /// ViewModel退
final class DepositOrderListViewModel { final class DepositOrderListViewModel: ObservableObject {
private(set) var orders: [DepositOrderListItem] = [] @Published private(set) var orders: [DepositOrderListItem] = []
private(set) var total = 0 @Published private(set) var total = 0
private(set) var page = 1 @Published private(set) var page = 1
private(set) var hasMore = false @Published private(set) var hasMore = false
private(set) var loading = false @Published private(set) var loading = false
private(set) var loadingMore = false @Published private(set) var loadingMore = false
private(set) var operatingOrderNumber: String? @Published private(set) var operatingOrderNumber: String?
var errorMessage: String? @Published var errorMessage: String?
private let pageSize = 10 private let pageSize = 10
@ -134,12 +133,11 @@ final class DepositOrderListViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class DepositOrderDetailViewModel { final class DepositOrderDetailViewModel: ObservableObject {
private(set) var detail: StoreOrderDetailResponse? @Published private(set) var detail: StoreOrderDetailResponse?
private(set) var loading = false @Published private(set) var loading = false
var errorMessage: String? @Published var errorMessage: String?
/// ///
func load(api: OrderServing, storeId: Int?, orderNumber: String) async { func load(api: OrderServing, storeId: Int?, orderNumber: String) async {
@ -179,12 +177,11 @@ final class DepositOrderDetailViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class DepositOrderShootingInfoViewModel { final class DepositOrderShootingInfoViewModel: ObservableObject {
private(set) var detail: StoreOrderShootingDetailResponse? @Published private(set) var detail: StoreOrderShootingDetailResponse?
private(set) var loading = false @Published private(set) var loading = false
var errorMessage: String? @Published var errorMessage: String?
/// ///
func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async { func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async {
@ -229,14 +226,13 @@ final class DepositOrderShootingInfoViewModel {
} }
@MainActor @MainActor
@Observable
/// 退 ViewModel退 /// 退 ViewModel退
final class OrderRefundViewModel { final class OrderRefundViewModel: ObservableObject {
var mode: OrderRefundMode = .full @Published var mode: OrderRefundMode = .full
var amount = "" @Published var amount = ""
var reason = "" @Published var reason = ""
private(set) var submitting = false @Published private(set) var submitting = false
var errorMessage: String? @Published var errorMessage: String?
/// 退 /// 退
func canRefund(_ item: OrderEntity) -> Bool { func canRefund(_ item: OrderEntity) -> Bool {
@ -344,14 +340,13 @@ final class OrderRefundViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class HistoricalShootingInfoViewModel { final class HistoricalShootingInfoViewModel: ObservableObject {
private(set) var projectName = "" @Published private(set) var projectName = ""
private(set) var projectTypeName = "" @Published private(set) var projectTypeName = ""
private(set) var spots: [PhotogSpotItem] = [] @Published private(set) var spots: [PhotogSpotItem] = []
private(set) var loading = false @Published private(set) var loading = false
var errorMessage: String? @Published var errorMessage: String?
/// ///
func load(api: OrderServing, orderNumber: String) async { func load(api: OrderServing, orderNumber: String) async {
@ -390,20 +385,19 @@ final class HistoricalShootingInfoViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class MultiTravelTaskUploadViewModel { final class MultiTravelTaskUploadViewModel: ObservableObject {
var orderNumber = "" @Published var orderNumber = ""
var selectedSpotId: Int? @Published var selectedSpotId: Int?
private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = [] @Published private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = []
var selectedCloudFiles: [TaskCloudSelectionItem] = [] @Published var selectedCloudFiles: [TaskCloudSelectionItem] = []
var selectedLocalFiles: [TaskLocalUploadItem] = [] @Published var selectedLocalFiles: [TaskLocalUploadItem] = []
private(set) var isLoadingSpots = false @Published private(set) var isLoadingSpots = false
private(set) var isSubmitting = false @Published private(set) var isSubmitting = false
private(set) var didSubmitSuccessfully = false @Published private(set) var didSubmitSuccessfully = false
var errorMessage: String? @Published var errorMessage: String?
private var loadedSpotOrderNumber = "" @Published private var loadedSpotOrderNumber = ""
/// ///
var selectedSpotName: String { var selectedSpotName: String {

View File

@ -6,31 +6,30 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class OrdersViewModel { final class OrdersViewModel: ObservableObject {
var selectedEntry: OrdersEntry = .storeOrders @Published var selectedEntry: OrdersEntry = .storeOrders
var selectedStatus = 0 @Published var selectedStatus = 0
var searchPhone = "" @Published var searchPhone = ""
var filterStartDate: Date? @Published var filterStartDate: Date?
var filterEndDate: Date? @Published var filterEndDate: Date?
private(set) var loading = false @Published private(set) var loading = false
private(set) var loadingMore = false @Published private(set) var loadingMore = false
private(set) var isVerifying = false @Published private(set) var isVerifying = false
private(set) var currentVerifyingOrderNumber: String? @Published private(set) var currentVerifyingOrderNumber: String?
private(set) var storeOrders: [OrderEntity] = [] @Published private(set) var storeOrders: [OrderEntity] = []
private(set) var storeTotal = 0 @Published private(set) var storeTotal = 0
private(set) var storePage = 1 @Published private(set) var storePage = 1
private(set) var storeHasMore = false @Published private(set) var storeHasMore = false
private(set) var writeOffOrders: [WriteOffOrderItem] = [] @Published private(set) var writeOffOrders: [WriteOffOrderItem] = []
private(set) var writeOffTotal = 0 @Published private(set) var writeOffTotal = 0
private(set) var writeOffPage = 1 @Published private(set) var writeOffPage = 1
private(set) var writeOffHasMore = false @Published private(set) var writeOffHasMore = false
/// ///
var normalizedPhone: String? { var normalizedPhone: String? {

View File

@ -9,13 +9,13 @@ import SwiftUI
/// 退 /// 退
struct DepositOrderEntryView: View { struct DepositOrderEntryView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(OrdersAPI.self) private var ordersAPI @Environment(\.ordersAPI) private var ordersAPI
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = DepositOrderListViewModel() @StateObject private var viewModel = DepositOrderListViewModel()
@State private var orderNumber = "" @State private var orderNumber = ""
@State private var pendingWriteOff: DepositOrderListItem? @State private var pendingWriteOff: DepositOrderListItem?
@State private var pendingRefund: DepositOrderListItem? @State private var pendingRefund: DepositOrderListItem?
@ -36,7 +36,7 @@ struct DepositOrderEntryView: View {
Section { Section {
if viewModel.orders.isEmpty { if viewModel.orders.isEmpty {
ContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass") AppContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass")
.frame(maxWidth: .infinity, minHeight: 180) .frame(maxWidth: .infinity, minHeight: 180)
} else { } else {
ForEach(viewModel.orders) { item in ForEach(viewModel.orders) { item in
@ -239,13 +239,13 @@ private struct DepositOrderRow: View {
/// ///
struct DepositOrderDetailView: View { struct DepositOrderDetailView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(OrdersAPI.self) private var ordersAPI @Environment(\.ordersAPI) private var ordersAPI
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
let orderNumber: String let orderNumber: String
@State private var viewModel = DepositOrderDetailViewModel() @StateObject private var viewModel = DepositOrderDetailViewModel()
var body: some View { var body: some View {
List { List {
@ -275,7 +275,7 @@ struct DepositOrderDetailView: View {
Section("拍摄详情") { Section("拍摄详情") {
let shootingList = detail.multiTravel?.shootingList ?? [] let shootingList = detail.multiTravel?.shootingList ?? []
if shootingList.isEmpty { if shootingList.isEmpty {
ContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack") AppContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack")
} else { } else {
ForEach(shootingList) { item in ForEach(shootingList) { item in
Button { Button {
@ -296,7 +296,7 @@ struct DepositOrderDetailView: View {
} }
} else { } else {
Section { Section {
ContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass") AppContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass")
.frame(maxWidth: .infinity, minHeight: 220) .frame(maxWidth: .infinity, minHeight: 220)
} }
} }
@ -363,15 +363,15 @@ private struct DepositShootingPointRow: View {
/// ///
struct DepositOrderShootingInfoView: View { struct DepositOrderShootingInfoView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(OrdersAPI.self) private var ordersAPI @Environment(\.ordersAPI) private var ordersAPI
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
let orderNumber: String let orderNumber: String
let scenicSpotId: Int let scenicSpotId: Int
let photogUid: Int let photogUid: Int
@State private var viewModel = DepositOrderShootingInfoViewModel() @StateObject private var viewModel = DepositOrderShootingInfoViewModel()
@State private var selectedTab: DepositShootingMediaTab = .negative @State private var selectedTab: DepositShootingMediaTab = .negative
var body: some View { var body: some View {
@ -393,7 +393,7 @@ struct DepositOrderShootingInfoView: View {
.font(.system(size: AppMetrics.FontSize.subheadline)) .font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary) .foregroundStyle(AppDesign.textSecondary)
} else { } else {
ContentUnavailableView("暂无评价信息", systemImage: "star") AppContentUnavailableView("暂无评价信息", systemImage: "star")
} }
} }
@ -410,7 +410,7 @@ struct DepositOrderShootingInfoView: View {
} }
} else { } else {
Section { Section {
ContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack") AppContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack")
.frame(maxWidth: .infinity, minHeight: 220) .frame(maxWidth: .infinity, minHeight: 220)
} }
} }
@ -448,11 +448,11 @@ struct DepositOrderShootingInfoView: View {
/// ///
struct HistoricalShootingInfoView: View { struct HistoricalShootingInfoView: View {
@Environment(OrdersAPI.self) private var ordersAPI @Environment(\.ordersAPI) private var ordersAPI
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
let orderNumber: String let orderNumber: String
@State private var viewModel = HistoricalShootingInfoViewModel() @StateObject private var viewModel = HistoricalShootingInfoViewModel()
var body: some View { var body: some View {
List { List {
@ -464,7 +464,7 @@ struct HistoricalShootingInfoView: View {
Section("历史拍摄") { Section("历史拍摄") {
if viewModel.spots.isEmpty { if viewModel.spots.isEmpty {
ContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle") AppContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle")
.frame(maxWidth: .infinity, minHeight: 220) .frame(maxWidth: .infinity, minHeight: 220)
} else { } else {
ForEach(viewModel.spots) { spot in ForEach(viewModel.spots) { spot in
@ -565,7 +565,7 @@ private struct OrderMediaGrid: View {
var body: some View { var body: some View {
if files.isEmpty { if files.isEmpty {
ContentUnavailableView("暂无媒体文件", systemImage: "photo") AppContentUnavailableView("暂无媒体文件", systemImage: "photo")
.frame(maxWidth: .infinity, minHeight: 140) .frame(maxWidth: .infinity, minHeight: 140)
} else { } else {
LazyVGrid(columns: columns, spacing: AppMetrics.Spacing.small) { LazyVGrid(columns: columns, spacing: AppMetrics.Spacing.small) {

View File

@ -9,21 +9,21 @@ import SwiftUI
/// ///
struct StoreOrderDetailView: View { struct StoreOrderDetailView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(OrdersAPI.self) private var ordersAPI @Environment(\.ordersAPI) private var ordersAPI
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
let item: OrderEntity let item: OrderEntity
@State private var viewModel: OrderDetailViewModel @StateObject private var viewModel: OrderDetailViewModel
@State private var refundViewModel = OrderRefundViewModel() @StateObject private var refundViewModel = OrderRefundViewModel()
@State private var showRefundSheet = false @State private var showRefundSheet = false
/// 使 ViewModel /// 使 ViewModel
init(item: OrderEntity) { init(item: OrderEntity) {
self.item = item self.item = item
_viewModel = State(initialValue: OrderDetailViewModel(item: item)) _viewModel = StateObject(wrappedValue: OrderDetailViewModel(item: item))
} }
var body: some View { var body: some View {
@ -204,7 +204,7 @@ struct StoreOrderDetailView: View {
/// 退退 /// 退退
private struct OrderRefundSheet: View { private struct OrderRefundSheet: View {
let item: OrderEntity let item: OrderEntity
@Bindable var viewModel: OrderRefundViewModel @ObservedObject var viewModel: OrderRefundViewModel
let onCancel: () -> Void let onCancel: () -> Void
let onSubmit: () -> Void let onSubmit: () -> Void
@FocusState private var focusedField: RefundInputField? @FocusState private var focusedField: RefundInputField?
@ -276,9 +276,9 @@ private enum RefundInputField: Hashable {
/// ///
struct WriteOffOrderDetailView: View { struct WriteOffOrderDetailView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(OrdersAPI.self) private var ordersAPI @Environment(\.ordersAPI) private var ordersAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
let item: WriteOffOrderItem let item: WriteOffOrderItem
@State private var verifying = false @State private var verifying = false

View File

@ -11,21 +11,21 @@ import UniformTypeIdentifiers
/// ///
struct MultiTravelTaskUploadView: View { struct MultiTravelTaskUploadView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(OrdersAPI.self) private var ordersAPI @Environment(\.ordersAPI) private var ordersAPI
@Environment(OSSUploadService.self) private var uploadService @Environment(\.ossUploadService) private var uploadService
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel: MultiTravelTaskUploadViewModel @StateObject private var viewModel: MultiTravelTaskUploadViewModel
@State private var showSpotPicker = false @State private var showSpotPicker = false
@State private var showCloudSelection = false @State private var showCloudSelection = false
@State private var pickerItems: [PhotosPickerItem] = [] @State private var pickerItems: [PhotosPickerItem] = []
/// 使 /// 使
init(initialOrderNumber: String) { init(initialOrderNumber: String) {
_viewModel = State(initialValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber)) _viewModel = StateObject(wrappedValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber))
} }
var body: some View { var body: some View {
@ -61,7 +61,7 @@ struct MultiTravelTaskUploadView: View {
} }
Button("取消", role: .cancel) {} Button("取消", role: .cancel) {}
} }
.onChange(of: pickerItems) { _, items in .onChange(of: pickerItems) { items in
Task { await importPickerItems(items) } Task { await importPickerItems(items) }
} }
.alert("提示", isPresented: errorBinding) { .alert("提示", isPresented: errorBinding) {

View File

@ -9,16 +9,16 @@ import SwiftUI
/// Tab /// Tab
struct OrdersView: View { struct OrdersView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(PermissionContext.self) private var permissionContext @EnvironmentObject private var permissionContext: PermissionContext
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(OrdersAPI.self) private var ordersAPI @Environment(\.ordersAPI) private var ordersAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = OrdersViewModel() @StateObject private var viewModel = OrdersViewModel()
@State private var manualOrderNumber = "" @State private var manualOrderNumber = ""
@State private var showDateFilterSheet = false @State private var showDateFilterSheet = false
@State private var showScanner = false @State private var showScanner = false
@ -62,7 +62,7 @@ struct OrdersView: View {
entrySection entrySection
if currentScenicId == nil { if currentScenicId == nil {
ContentUnavailableView( AppContentUnavailableView(
"缺少经营上下文", "缺少经营上下文",
systemImage: "mountain.2", systemImage: "mountain.2",
description: Text("请先在首页选择景区后查看订单。") description: Text("请先在首页选择景区后查看订单。")
@ -80,7 +80,7 @@ struct OrdersView: View {
.padding(.bottom, AppMetrics.Spacing.pageVertical) .padding(.bottom, AppMetrics.Spacing.pageVertical)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.onChange(of: matchedScannedOrderNumber) { _, orderNumber in .onChange(of: matchedScannedOrderNumber) { orderNumber in
guard let orderNumber else { return } guard let orderNumber else { return }
withAnimation(.easeInOut(duration: 0.25)) { withAnimation(.easeInOut(duration: 0.25)) {
scrollProxy.scrollTo(orderNumber, anchor: .center) scrollProxy.scrollTo(orderNumber, anchor: .center)
@ -96,15 +96,15 @@ struct OrdersView: View {
await reload() await reload()
await consumePendingScanCodeIfNeeded() await consumePendingScanCodeIfNeeded()
} }
.onChange(of: appRouter.selectedOrdersEntry) { _, entry in .onChange(of: appRouter.selectedOrdersEntry) { entry in
guard viewModel.selectedEntry != entry else { return } guard viewModel.selectedEntry != entry else { return }
viewModel.selectedEntry = entry viewModel.selectedEntry = entry
Task { await reload() } Task { await reload() }
} }
.onChange(of: appRouter.pendingOrderScanCode) { _, _ in .onChange(of: appRouter.pendingOrderScanCode) { _ in
Task { await consumePendingScanCodeIfNeeded() } Task { await consumePendingScanCodeIfNeeded() }
} }
.onChange(of: accountContext.currentScenic?.id) { _, _ in .onChange(of: accountContext.currentScenic?.id) { _ in
Task { await reload() } Task { await reload() }
} }
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) { .confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
@ -233,7 +233,7 @@ struct OrdersView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(minHeight: 260) .frame(minHeight: 260)
} else if viewModel.storeOrders.isEmpty { } else if viewModel.storeOrders.isEmpty {
ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。")) AppContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
.frame(minHeight: 260) .frame(minHeight: 260)
} else { } else {
ForEach(viewModel.storeOrders) { item in ForEach(viewModel.storeOrders) { item in
@ -306,7 +306,7 @@ struct OrdersView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(minHeight: 260) .frame(minHeight: 260)
} else if viewModel.writeOffOrders.isEmpty { } else if viewModel.writeOffOrders.isEmpty {
ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。")) AppContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
.frame(minHeight: 260) .frame(minHeight: 260)
} else { } else {
ForEach(viewModel.writeOffOrders) { item in ForEach(viewModel.writeOffOrders) { item in
@ -735,11 +735,11 @@ private struct OrdersDateFilterSheet: View {
#Preview { #Preview {
NavigationStack { NavigationStack {
OrdersView() OrdersView()
.environment(AccountContext()) .environmentObject(AccountContext())
.environment(PermissionContext()) .environmentObject(PermissionContext())
.environment(AppRouter()) .environmentObject(AppRouter())
.environment(RouterPath()) .environmentObject(RouterPath())
.environment(OrdersAPI(client: APIClient())) .environment(\.ordersAPI, OrdersAPI(client: APIClient()))
.environment(ToastCenter()) .environmentObject(ToastCenter())
} }
} }

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
@MainActor @MainActor
@ -19,10 +19,9 @@ protocol PaymentServing {
} }
@MainActor @MainActor
@Observable
/// API /// API
final class PaymentAPI { final class PaymentAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -8,25 +8,24 @@
import CoreImage import CoreImage
import CoreImage.CIFilterBuiltins import CoreImage.CIFilterBuiltins
import Foundation import Foundation
import Observation import Combine
import UIKit import UIKit
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class PaymentCollectionViewModel { final class PaymentCollectionViewModel: ObservableObject {
var staticPayUrl = "" @Published var staticPayUrl = ""
var dynamicPayUrl = "" @Published var dynamicPayUrl = ""
var amountText = "" @Published var amountText = ""
var remarkText = "" @Published var remarkText = ""
var currentPayUrl = "" @Published var currentPayUrl = ""
var qrImage: UIImage? @Published var qrImage: UIImage?
var isLoading = false @Published var isLoading = false
var isPolling = false @Published var isPolling = false
var errorMessage: String? @Published var errorMessage: String?
var status: PaymentCollectionStatus = .idle @Published var status: PaymentCollectionStatus = .idle
private var knownRecordIDs = Set<String>() @Published private var knownRecordIDs = Set<String>()
private let context = CIContext() private let context = CIContext()
private let qrFilter = CIFilter.qrCodeGenerator() private let qrFilter = CIFilter.qrCodeGenerator()
@ -191,12 +190,11 @@ private extension String {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class PaymentCollectionRecordViewModel { final class PaymentCollectionRecordViewModel: ObservableObject {
var groups: [PaymentCollectionRecordGroup] = [] @Published var groups: [PaymentCollectionRecordGroup] = []
var isLoading = false @Published var isLoading = false
var errorMessage: String? @Published var errorMessage: String?
/// ///
func load(api: PaymentServing, scenicId: Int?) async { func load(api: PaymentServing, scenicId: Int?) async {

View File

@ -11,12 +11,12 @@ import SwiftUI
/// ///
struct PaymentCollectionView: View { struct PaymentCollectionView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(PaymentAPI.self) private var paymentAPI @Environment(\.paymentAPI) private var paymentAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = PaymentCollectionViewModel() @StateObject private var viewModel = PaymentCollectionViewModel()
@State private var showingAmountSheet = false @State private var showingAmountSheet = false
@State private var pollingTask: Task<Void, Never>? @State private var pollingTask: Task<Void, Never>?
@State private var speaker = PaymentVoiceSpeaker() @State private var speaker = PaymentVoiceSpeaker()
@ -56,12 +56,12 @@ struct PaymentCollectionView: View {
.onDisappear { .onDisappear {
pollingTask?.cancel() pollingTask?.cancel()
} }
.onChange(of: viewModel.errorMessage) { _, message in .onChange(of: viewModel.errorMessage) { message in
if let message { if let message {
toastCenter.show(message) toastCenter.show(message)
} }
} }
.onChange(of: viewModel.status) { _, status in .onChange(of: viewModel.status) { status in
if case .success(let item) = status { if case .success(let item) = status {
speaker.speak("收款到账\(item.orderAmount)") speaker.speak("收款到账\(item.orderAmount)")
} }
@ -256,11 +256,11 @@ struct PaymentCollectionView: View {
/// ///
struct PaymentCollectionRecordView: View { struct PaymentCollectionRecordView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(PaymentAPI.self) private var paymentAPI @Environment(\.paymentAPI) private var paymentAPI
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = PaymentCollectionRecordViewModel() @StateObject private var viewModel = PaymentCollectionRecordViewModel()
var body: some View { var body: some View {
List { List {
@ -304,7 +304,7 @@ struct PaymentCollectionRecordView: View {
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id) await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
} }
} }
.onChange(of: viewModel.errorMessage) { _, message in .onChange(of: viewModel.errorMessage) { message in
if let message { if let message {
toastCenter.show(message) toastCenter.show(message)
} }
@ -314,9 +314,8 @@ struct PaymentCollectionRecordView: View {
/// 使 TTS /// 使 TTS
@MainActor @MainActor
@Observable
final class PaymentVoiceSpeaker { final class PaymentVoiceSpeaker {
@ObservationIgnored private let synthesizer = AVSpeechSynthesizer() private let synthesizer = AVSpeechSynthesizer()
/// ///
func speak(_ text: String) { func speak(_ text: String) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
@MainActor @MainActor
@ -18,10 +18,9 @@ protocol PilotCertificationServing {
} }
@MainActor @MainActor
@Observable
/// API `/api/app/flyer` /// API `/api/app/flyer`
final class PilotCertificationAPI { final class PilotCertificationAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
/// 便 ViewModel /// 便 ViewModel
@ -17,29 +17,28 @@ protocol PilotRealNameServing {
extension ProfileAPI: PilotRealNameServing {} extension ProfileAPI: PilotRealNameServing {}
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class PilotCertificationViewModel { final class PilotCertificationViewModel: ObservableObject {
private(set) var flyer: FlyerDetailResponse? @Published private(set) var flyer: FlyerDetailResponse?
private(set) var realNameInfo: RealNameInfo? @Published private(set) var realNameInfo: RealNameInfo?
var name = "" @Published var name = ""
var certType: PilotCertType = .caac @Published var certType: PilotCertType = .caac
var certNo = "" @Published var certNo = ""
var certImageUrl = "" @Published var certImageUrl = ""
var startDate = Date() @Published var startDate = Date()
var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date() @Published var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date()
var droneModel = "" @Published var droneModel = ""
var droneSerialNo = "" @Published var droneSerialNo = ""
var phone = "" @Published var phone = ""
var verifyCode = "" @Published var verifyCode = ""
private(set) var pendingCertificateImageData: Data? @Published private(set) var pendingCertificateImageData: Data?
private(set) var pendingCertificateFileName: String? @Published private(set) var pendingCertificateFileName: String?
private(set) var uploadProgress: Int? @Published private(set) var uploadProgress: Int?
private(set) var loading = false @Published private(set) var loading = false
private(set) var sendingCode = false @Published private(set) var sendingCode = false
private(set) var submitting = false @Published private(set) var submitting = false
private(set) var countdown = 0 @Published private(set) var countdown = 0
var statusMessage: String? @Published var statusMessage: String?
/// ///
func load(api: any PilotCertificationServing, realNameAPI: any PilotRealNameServing) async { func load(api: any PilotCertificationServing, realNameAPI: any PilotRealNameServing) async {

View File

@ -12,14 +12,14 @@ import UIKit
/// / /// /
struct PilotCertificationView: View { struct PilotCertificationView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(ProfileAPI.self) private var profileAPI @Environment(\.profileAPI) private var profileAPI
@Environment(PilotCertificationAPI.self) private var pilotAPI @Environment(\.pilotCertificationAPI) private var pilotAPI
@Environment(OSSUploadService.self) private var ossUploadService @Environment(\.ossUploadService) private var ossUploadService
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = PilotCertificationViewModel() @StateObject private var viewModel = PilotCertificationViewModel()
@State private var selectedImageItem: PhotosPickerItem? @State private var selectedImageItem: PhotosPickerItem?
private let countdownTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private let countdownTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@ -55,7 +55,7 @@ struct PilotCertificationView: View {
.onReceive(countdownTimer) { _ in .onReceive(countdownTimer) { _ in
viewModel.tickCountdown() viewModel.tickCountdown()
} }
.onChange(of: selectedImageItem) { _, item in .onChange(of: selectedImageItem) { item in
guard let item else { return } guard let item else { return }
Task { await loadImage(item) } Task { await loadImage(item) }
} }

View File

@ -6,13 +6,12 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// API /// API
final class ProfileAPI { final class ProfileAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,17 +6,16 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class AccountSwitchViewModel { final class AccountSwitchViewModel: ObservableObject {
private(set) var accounts: [AccountSwitchAccount] = [] @Published private(set) var accounts: [AccountSwitchAccount] = []
var selectedAccountId: String? @Published var selectedAccountId: String?
private(set) var loading = false @Published private(set) var loading = false
private(set) var switching = false @Published private(set) var switching = false
private var didLoad = false @Published private var didLoad = false
/// ///
var selectedAccount: AccountSwitchAccount? { var selectedAccount: AccountSwitchAccount? {

View File

@ -6,21 +6,20 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class ProfileViewModel { final class ProfileViewModel: ObservableObject {
var userInfo: UserInfoResponse? @Published var userInfo: UserInfoResponse?
var realNameInfo: RealNameInfo? @Published var realNameInfo: RealNameInfo?
var isLoading = false @Published var isLoading = false
var isSaving = false @Published var isSaving = false
var isEditingProfile = false @Published var isEditingProfile = false
var editingNickname = "" @Published var editingNickname = ""
private(set) var pendingAvatarData: Data? @Published private(set) var pendingAvatarData: Data?
private(set) var pendingAvatarFileName: String? @Published private(set) var pendingAvatarFileName: String?
private(set) var avatarUploadProgress: Int? @Published private(set) var avatarUploadProgress: Int?
var displayNickname: String { var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称" nonEmpty(userInfo?.nickname) ?? "未设置昵称"

View File

@ -6,31 +6,30 @@
// //
import Foundation import Foundation
import Observation import Combine
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class RealNameAuthViewModel { final class RealNameAuthViewModel: ObservableObject {
private(set) var info: RealNameInfo? @Published private(set) var info: RealNameInfo?
var realName = "" @Published var realName = ""
var idCardNo = "" @Published var idCardNo = ""
var smsCode = "" @Published var smsCode = ""
var startDate = Date() @Published var startDate = Date()
var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date() @Published var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
var isLongValid = false @Published var isLongValid = false
var frontUrl = "" @Published var frontUrl = ""
var backUrl = "" @Published var backUrl = ""
private(set) var pendingFrontImageData: Data? @Published private(set) var pendingFrontImageData: Data?
private(set) var pendingBackImageData: Data? @Published private(set) var pendingBackImageData: Data?
private(set) var frontUploadProgress: Int? @Published private(set) var frontUploadProgress: Int?
private(set) var backUploadProgress: Int? @Published private(set) var backUploadProgress: Int?
private var pendingFrontFileName: String? @Published private var pendingFrontFileName: String?
private var pendingBackFileName: String? @Published private var pendingBackFileName: String?
private(set) var loading = false @Published private(set) var loading = false
private(set) var sendingCode = false @Published private(set) var sendingCode = false
private(set) var submitting = false @Published private(set) var submitting = false
var statusMessage: String? @Published var statusMessage: String?
/// ///
func load(api: ProfileAPI) async throws { func load(api: ProfileAPI) async throws {

View File

@ -9,19 +9,19 @@ import SwiftUI
/// ///
struct AccountSwitchView: View { struct AccountSwitchView: View {
@Environment(AppSession.self) private var appSession @EnvironmentObject private var appSession: AppSession
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(PermissionContext.self) private var permissionContext @EnvironmentObject private var permissionContext: PermissionContext
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
@Environment(ProfileAPI.self) private var profileAPI @Environment(\.profileAPI) private var profileAPI
@Environment(AuthAPI.self) private var authAPI @Environment(\.authAPI) private var authAPI
@Environment(AccountContextAPI.self) private var accountContextAPI @Environment(\.accountContextAPI) private var accountContextAPI
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator @Environment(\.authSessionCoordinator) private var authSessionCoordinator
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var viewModel = AccountSwitchViewModel() @StateObject private var viewModel = AccountSwitchViewModel()
private var selectedAccount: AccountSwitchAccount? { private var selectedAccount: AccountSwitchAccount? {
viewModel.selectedAccount viewModel.selectedAccount
@ -43,7 +43,7 @@ struct AccountSwitchView: View {
@ViewBuilder @ViewBuilder
private var accountList: some View { private var accountList: some View {
if viewModel.accounts.isEmpty && !viewModel.loading { if viewModel.accounts.isEmpty && !viewModel.loading {
ContentUnavailableView( AppContentUnavailableView(
"暂无可切换账号", "暂无可切换账号",
systemImage: "person.crop.circle.badge.exclamationmark", systemImage: "person.crop.circle.badge.exclamationmark",
description: Text("当前登录账号下没有其他可切换账号。") description: Text("当前登录账号下没有其他可切换账号。")

View File

@ -10,9 +10,9 @@ import SwiftUI
/// ///
struct DebugHomeMenuPreviewView: View { struct DebugHomeMenuPreviewView: View {
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
private let items = HomeMenuRouter.debugAllMenuItems() private let items = HomeMenuRouter.debugAllMenuItems()

View File

@ -11,20 +11,20 @@ import UIKit
/// 退 /// 退
struct ProfileView: View { struct ProfileView: View {
@Environment(AppSession.self) private var appSession @EnvironmentObject private var appSession: AppSession
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(PermissionContext.self) private var permissionContext @EnvironmentObject private var permissionContext: PermissionContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext
@Environment(AppRouter.self) private var appRouter @EnvironmentObject private var appRouter: AppRouter
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(ProfileAPI.self) private var profileAPI @Environment(\.profileAPI) private var profileAPI
@Environment(OSSUploadService.self) private var ossUploadService @Environment(\.ossUploadService) private var ossUploadService
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator @Environment(\.authSessionCoordinator) private var authSessionCoordinator
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = ProfileViewModel() @StateObject private var viewModel = ProfileViewModel()
@State private var showsPasswordSheet = false @State private var showsPasswordSheet = false
@State private var showsLogoutConfirm = false @State private var showsLogoutConfirm = false
@State private var pickedAvatarItem: PhotosPickerItem? @State private var pickedAvatarItem: PhotosPickerItem?
@ -74,13 +74,13 @@ struct ProfileView: View {
} }
Button("取消", role: .cancel) {} Button("取消", role: .cancel) {}
} }
.onChange(of: viewModel.isEditingProfile) { _, isEditing in .onChange(of: viewModel.isEditingProfile) { isEditing in
guard isEditing else { return } guard isEditing else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
nicknameFocused = true nicknameFocused = true
} }
} }
.onChange(of: pickedAvatarItem) { _, item in .onChange(of: pickedAvatarItem) { item in
Task { await prepareAvatarImage(from: item) } Task { await prepareAvatarImage(from: item) }
} }
} }
@ -662,14 +662,14 @@ private struct PasswordUpdateSheet: View {
#Preview { #Preview {
NavigationStack { NavigationStack {
ProfileView() ProfileView()
.environment(AppSession()) .environmentObject(AppSession())
.environment(AccountContext()) .environmentObject(AccountContext())
.environment(PermissionContext()) .environmentObject(PermissionContext())
.environment(ScenicSpotContext()) .environmentObject(ScenicSpotContext())
.environment(AppRouter()) .environmentObject(AppRouter())
.environment(ToastCenter()) .environmentObject(ToastCenter())
.environment(ProfileAPI(client: APIClient())) .environment(\.profileAPI, ProfileAPI(client: APIClient()))
.environment(OSSUploadService(configService: UploadAPI(client: APIClient()))) .environment(\.ossUploadService, OSSUploadService(configService: UploadAPI(client: APIClient())))
.environment(AuthSessionCoordinator()) .environment(\.authSessionCoordinator, AuthSessionCoordinator())
} }
} }

View File

@ -11,12 +11,12 @@ import UIKit
/// ///
struct RealNameAuthView: View { struct RealNameAuthView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(ProfileAPI.self) private var profileAPI @Environment(\.profileAPI) private var profileAPI
@Environment(OSSUploadService.self) private var ossUploadService @Environment(\.ossUploadService) private var ossUploadService
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = RealNameAuthViewModel() @StateObject private var viewModel = RealNameAuthViewModel()
@State private var pickedFrontItem: PhotosPickerItem? @State private var pickedFrontItem: PhotosPickerItem?
@State private var pickedBackItem: PhotosPickerItem? @State private var pickedBackItem: PhotosPickerItem?
@ -38,10 +38,10 @@ struct RealNameAuthView: View {
.task { .task {
await loadInfo() await loadInfo()
} }
.onChange(of: pickedFrontItem) { _, item in .onChange(of: pickedFrontItem) { item in
Task { await prepareIdentityImage(from: item, side: .front) } Task { await prepareIdentityImage(from: item, side: .front) }
} }
.onChange(of: pickedBackItem) { _, item in .onChange(of: pickedBackItem) { item in
Task { await prepareIdentityImage(from: item, side: .back) } Task { await prepareIdentityImage(from: item, side: .back) }
} }
} }

View File

@ -53,7 +53,7 @@ enum SettingsDisplayPolicy {
/// ///
struct SettingsCenterView: View { struct SettingsCenterView: View {
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@State private var copiedDownloadLink = false @State private var copiedDownloadLink = false
private var versionText: String { private var versionText: String {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// 便 ViewModel /// 便 ViewModel
@MainActor @MainActor
@ -56,9 +56,8 @@ protocol ProjectServing {
/// API /// API
@MainActor @MainActor
@Observable
final class ProjectAPI: ProjectServing { final class ProjectAPI: ProjectServing {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
enum ProjectEditorMode: Equatable { enum ProjectEditorMode: Equatable {
@ -48,18 +48,17 @@ enum ProjectEditorError: LocalizedError, Equatable {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class ProjectManagementViewModel: ObservableObject {
final class ProjectManagementViewModel { @Published private(set) var items: [PhotographerProjectItem] = []
private(set) var items: [PhotographerProjectItem] = [] @Published private(set) var loading = false
private(set) var loading = false @Published private(set) var loadingMore = false
private(set) var loadingMore = false @Published private(set) var hasMore = false
private(set) var hasMore = false @Published private(set) var selectedDetail: PhotographerProjectDetailResponse?
private(set) var selectedDetail: PhotographerProjectDetailResponse? @Published var searchText = ""
var searchText = "" @Published var errorMessage: String?
var errorMessage: String?
private var page = 1 @Published private var page = 1
private var total = 0 @Published private var total = 0
private let pageSize = 10 private let pageSize = 10
/// ///
@ -156,47 +155,54 @@ final class ProjectManagementViewModel {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class ProjectEditorViewModel: ObservableObject {
final class ProjectEditorViewModel { @Published var name = ""
var name = "" @Published var descriptionText = ""
var descriptionText = "" @Published var price = ""
var price = "" @Published var otPrice = ""
var otPrice = "" @Published var deposit = ""
var deposit = "" @Published var attrLabelText = ""
var attrLabelText = "" @Published var materialNum = "1"
var materialNum = "1" @Published var photoNum = "1"
var photoNum = "1" @Published var videoNum = "1"
var videoNum = "1" @Published var selectedSpotIds: Set<Int> = []
var selectedSpotIds: Set<Int> = [] @Published var coverImage: ProjectLocalImage?
var coverImage: ProjectLocalImage? @Published var carouselImages: [ProjectLocalImage] = []
var carouselImages: [ProjectLocalImage] = [] @Published var existingCoverURL = ""
var existingCoverURL = "" @Published var existingCarouselURLs: [String] = []
var existingCarouselURLs: [String] = [] @Published private(set) var submitting = false
private(set) var submitting = false @Published private(set) var uploadProgress = 0
private(set) var uploadProgress = 0 @Published var errorMessage: String?
var errorMessage: String?
private let mode: ProjectEditorMode private var mode: ProjectEditorMode
/// ///
init(mode: ProjectEditorMode) { init(mode: ProjectEditorMode) {
self.mode = mode self.mode = mode
if case let .edit(detail) = mode { if case let .edit(detail) = mode {
name = detail.name apply(detail)
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
} }
} }
/// 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( func submit(
scenicId: Int?, scenicId: Int?,
@ -304,14 +310,13 @@ final class ProjectEditorViewModel {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class StoreProjectManagementViewModel: ObservableObject {
final class StoreProjectManagementViewModel { @Published private(set) var items: [PhotographerProjectItem] = []
private(set) var items: [PhotographerProjectItem] = [] @Published private(set) var loading = false
private(set) var loading = false @Published private(set) var selectedDetail: PhotographerProjectDetailResponse?
private(set) var selectedDetail: PhotographerProjectDetailResponse? @Published var searchText = ""
var searchText = "" @Published var selectedType = 0
var selectedType = 0 @Published var errorMessage: String?
var errorMessage: String?
/// ///
var filteredItems: [PhotographerProjectItem] { var filteredItems: [PhotographerProjectItem] {
@ -364,51 +369,58 @@ final class StoreProjectManagementViewModel {
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class StoreProjectEditorViewModel: ObservableObject {
final class StoreProjectEditorViewModel { @Published var projectType = 19
var projectType = 19 @Published var scenicList: [StoreManagerScenicItem] = []
var scenicList: [StoreManagerScenicItem] = [] @Published var storeList: [StoreItem] = []
var storeList: [StoreItem] = [] @Published var selectedScenicIds: Set<Int> = []
var selectedScenicIds: Set<Int> = [] @Published var selectedStoreId: Int?
var selectedStoreId: Int? @Published var name = ""
var name = "" @Published var descriptionText = ""
var descriptionText = "" @Published var coverImage: ProjectLocalImage?
var coverImage: ProjectLocalImage? @Published var carouselImages: [ProjectLocalImage] = []
var carouselImages: [ProjectLocalImage] = [] @Published var existingCoverURL = ""
var existingCoverURL = "" @Published var existingCarouselURLs: [String] = []
var existingCarouselURLs: [String] = [] @Published var settleSpotNum = "1"
var settleSpotNum = "1" @Published var projectPrice = ""
var projectPrice = "" @Published var priceMaterial = ""
var priceMaterial = "" @Published var pricePhoto = ""
var pricePhoto = "" @Published var priceVideo = ""
var priceVideo = "" @Published var priceDeposit = ""
var priceDeposit = "" @Published var materialNum = "1"
var materialNum = "1" @Published var photoNum = "1"
var photoNum = "1" @Published var videoNum = "1"
var videoNum = "1" @Published private(set) var submitting = false
private(set) var submitting = false @Published var errorMessage: String?
var errorMessage: String?
private let mode: StoreProjectEditorMode private var mode: StoreProjectEditorMode
/// ///
init(mode: StoreProjectEditorMode) { init(mode: StoreProjectEditorMode) {
self.mode = mode self.mode = mode
if case let .edit(detail) = mode { if case let .edit(detail) = mode {
projectType = detail.type == 4 ? 4 : 19 apply(detail)
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))"
} }
} }
/// 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 { func loadScenicList(api: any ProjectServing, userId: Int) async {
guard scenicList.isEmpty, userId > 0 else { return } guard scenicList.isEmpty, userId > 0 else { return }

View File

@ -11,17 +11,17 @@ import UniformTypeIdentifiers
/// ///
struct ProjectManagementView: View { struct ProjectManagementView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AccountSnapshotStore.self) private var snapshotStore @Environment(\.accountSnapshotStore) private var snapshotStore
@Environment(ProjectAPI.self) private var projectAPI @Environment(\.projectAPI) private var projectAPI
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = ProjectManagementViewModel() @StateObject private var viewModel = ProjectManagementViewModel()
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
filterBar filterBar
if accountContext.currentScenic?.id == nil { if accountContext.currentScenic?.id == nil {
ContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。")) AppContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。"))
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
projectList projectList
@ -66,7 +66,7 @@ struct ProjectManagementView: View {
ScrollView { ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.medium) { LazyVStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.items.isEmpty && !viewModel.loading { if viewModel.items.isEmpty && !viewModel.loading {
ContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。")) AppContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。"))
.frame(maxWidth: .infinity, minHeight: 260) .frame(maxWidth: .infinity, minHeight: 260)
} }
ForEach(viewModel.items) { item in ForEach(viewModel.items) { item in
@ -110,10 +110,10 @@ struct ProjectManagementView: View {
/// ///
struct StoreProjectManagementView: View { struct StoreProjectManagementView: View {
@Environment(AccountSnapshotStore.self) private var snapshotStore @Environment(\.accountSnapshotStore) private var snapshotStore
@Environment(ProjectAPI.self) private var projectAPI @Environment(\.projectAPI) private var projectAPI
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = StoreProjectManagementViewModel() @StateObject private var viewModel = StoreProjectManagementViewModel()
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -127,7 +127,7 @@ struct StoreProjectManagementView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
if viewModel.filteredItems.isEmpty && !viewModel.loading { if viewModel.filteredItems.isEmpty && !viewModel.loading {
ContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。")) AppContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。"))
.frame(minHeight: 260) .frame(minHeight: 260)
} }
} }
@ -196,7 +196,7 @@ struct StoreProjectManagementView: View {
/// ///
struct ProjectDetailView: View { struct ProjectDetailView: View {
@Environment(ProjectAPI.self) private var projectAPI @Environment(\.projectAPI) private var projectAPI
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
let projectId: Int let projectId: Int
let storeMode: Bool let storeMode: Bool
@ -209,7 +209,7 @@ struct ProjectDetailView: View {
if let detail { if let detail {
ProjectDetailContentView(detail: detail) ProjectDetailContentView(detail: detail)
} else if let errorMessage { } else if let errorMessage {
ContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage)) AppContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage))
.frame(minHeight: 360) .frame(minHeight: 360)
} else { } else {
ProgressView() ProgressView()
@ -247,14 +247,14 @@ struct ProjectDetailView: View {
/// ///
struct ProjectEditorView: View { struct ProjectEditorView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AccountSnapshotStore.self) private var snapshotStore @Environment(\.accountSnapshotStore) private var snapshotStore
@Environment(ScenicSpotContext.self) private var scenicSpotContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext
@Environment(ProjectAPI.self) private var projectAPI @Environment(\.projectAPI) private var projectAPI
@Environment(OSSUploadService.self) private var uploadService @Environment(\.ossUploadService) private var uploadService
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
let projectId: Int? 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 coverItem: PhotosPickerItem?
@State private var carouselItems: [PhotosPickerItem] = [] @State private var carouselItems: [PhotosPickerItem] = []
@State private var loadingDetail = false @State private var loadingDetail = false
@ -291,10 +291,10 @@ struct ProjectEditorView: View {
.task { .task {
await prepare() await prepare()
} }
.onChange(of: coverItem) { _, newValue in .onChange(of: coverItem) { newValue in
Task { await loadCover(newValue) } Task { await loadCover(newValue) }
} }
.onChange(of: carouselItems) { _, newValue in .onChange(of: carouselItems) { newValue in
Task { await loadCarousel(newValue) } Task { await loadCarousel(newValue) }
} }
} }
@ -387,7 +387,7 @@ struct ProjectEditorView: View {
defer { loadingDetail = false } defer { loadingDetail = false }
do { do {
let detail = try await projectAPI.projectDetail(id: projectId) let detail = try await projectAPI.projectDetail(id: projectId)
viewModel = ProjectEditorViewModel(mode: .edit(detail)) viewModel.apply(detail)
} catch { } catch {
viewModel.errorMessage = error.localizedDescription viewModel.errorMessage = error.localizedDescription
} }
@ -418,12 +418,12 @@ struct ProjectEditorView: View {
/// ///
struct StoreProjectEditorView: View { struct StoreProjectEditorView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(AccountSnapshotStore.self) private var snapshotStore @Environment(\.accountSnapshotStore) private var snapshotStore
@Environment(ProjectAPI.self) private var projectAPI @Environment(\.projectAPI) private var projectAPI
@Environment(OSSUploadService.self) private var uploadService @Environment(\.ossUploadService) private var uploadService
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
let projectId: Int? 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 coverItem: PhotosPickerItem?
@State private var carouselItems: [PhotosPickerItem] = [] @State private var carouselItems: [PhotosPickerItem] = []
@ -454,10 +454,10 @@ struct StoreProjectEditorView: View {
} }
} }
.task { await prepare() } .task { await prepare() }
.onChange(of: coverItem) { _, newValue in .onChange(of: coverItem) { newValue in
Task { viewModel.coverImage = await ProjectPickerLoader.loadImage(from: newValue) } 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) } Task { viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: newValue) }
} }
} }
@ -545,7 +545,7 @@ struct StoreProjectEditorView: View {
if let projectId { if let projectId {
do { do {
let detail = try await projectAPI.storeManagerProjectDetail(id: projectId) let detail = try await projectAPI.storeManagerProjectDetail(id: projectId)
viewModel = StoreProjectEditorViewModel(mode: .edit(detail)) viewModel.apply(detail)
} catch { } catch {
viewModel.errorMessage = error.localizedDescription viewModel.errorMessage = error.localizedDescription
} }

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// 便 /// 便
@MainActor @MainActor
@ -29,9 +29,8 @@ protocol PunchPointServing {
/// API /// API
@MainActor @MainActor
@Observable
final class PunchPointAPI: PunchPointServing { final class PunchPointAPI: PunchPointServing {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,21 +6,20 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ViewModel /// ViewModel
@MainActor @MainActor
@Observable final class PunchPointListViewModel: ObservableObject {
final class PunchPointListViewModel { @Published var selectedFilter: PunchPointFilter = .all
var selectedFilter: PunchPointFilter = .all @Published var items: [PunchPointItem] = []
var items: [PunchPointItem] = [] @Published var selectedDetail: PunchPointItem?
var selectedDetail: PunchPointItem? @Published var errorMessage: String?
var errorMessage: String? @Published var isLoading = false
var isLoading = false @Published var isLoadingMore = false
var isLoadingMore = false @Published var total = 0
var total = 0
private var page = 1 @Published private var page = 1
private let pageSize = 20 private let pageSize = 20
/// ///
@ -121,35 +120,41 @@ final class PunchPointListViewModel {
/// ViewModel OSS /// ViewModel OSS
@MainActor @MainActor
@Observable final class PunchPointEditorViewModel: ObservableObject {
final class PunchPointEditorViewModel { @Published var name = ""
var name = "" @Published var description = ""
var description = "" @Published var address = ""
var address = "" @Published var latitudeText = ""
var latitudeText = "" @Published var longitudeText = ""
var longitudeText = "" @Published var scenicSpotText = ""
var scenicSpotText = "" @Published var remoteImages: [String] = []
var remoteImages: [String] = [] @Published var localImages: [PunchPointLocalImage] = []
var localImages: [PunchPointLocalImage] = [] @Published var errorMessage: String?
var errorMessage: String? @Published var isSubmitting = false
var isSubmitting = false
let editingItem: PunchPointItem? private(set) var editingItem: PunchPointItem?
/// ViewModel /// ViewModel
init(item: PunchPointItem? = nil) { init(item: PunchPointItem? = nil) {
editingItem = item editingItem = item
if let item { if let item {
name = item.name apply(item)
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
} }
} }
/// 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) { func applyLocation(latitude: Double, longitude: Double, address: String) {
latitudeText = String(format: "%.6f", latitude) latitudeText = String(format: "%.6f", latitude)

View File

@ -11,15 +11,15 @@ import UIKit
/// ///
struct PunchPointListView: View { struct PunchPointListView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AccountContextAPI.self) private var accountContextAPI @Environment(\.accountContextAPI) private var accountContextAPI
@Environment(PunchPointAPI.self) private var punchPointAPI @Environment(\.punchPointAPI) private var punchPointAPI
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(ScenicSpotContext.self) private var scenicSpotContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = PunchPointListViewModel() @StateObject private var viewModel = PunchPointListViewModel()
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -58,7 +58,7 @@ struct PunchPointListView: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.onChange(of: viewModel.selectedFilter) { _, newValue in .onChange(of: viewModel.selectedFilter) { newValue in
Task { Task {
await globalLoading.withOptionalLoading(currentScenicId != nil) { await globalLoading.withOptionalLoading(currentScenicId != nil) {
await viewModel.selectFilter(newValue, scenicId: currentScenicId, api: punchPointAPI) await viewModel.selectFilter(newValue, scenicId: currentScenicId, api: punchPointAPI)
@ -116,15 +116,15 @@ struct PunchPointDetailView: View {
let punchPointId: Int let punchPointId: Int
let summary: PunchPointItem? let summary: PunchPointItem?
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AccountContextAPI.self) private var accountContextAPI @Environment(\.accountContextAPI) private var accountContextAPI
@Environment(PunchPointAPI.self) private var punchPointAPI @Environment(\.punchPointAPI) private var punchPointAPI
@Environment(RouterPath.self) private var router @EnvironmentObject private var router: RouterPath
@Environment(ScenicSpotContext.self) private var scenicSpotContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var viewModel = PunchPointListViewModel() @StateObject private var viewModel = PunchPointListViewModel()
@State private var showDeleteConfirm = false @State private var showDeleteConfirm = false
var item: PunchPointItem? { var item: PunchPointItem? {
@ -275,17 +275,17 @@ struct PunchPointDetailView: View {
struct PunchPointEditorView: View { struct PunchPointEditorView: View {
let punchPointId: Int? let punchPointId: Int?
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(AccountContextAPI.self) private var accountContextAPI @Environment(\.accountContextAPI) private var accountContextAPI
@Environment(PunchPointAPI.self) private var punchPointAPI @Environment(\.punchPointAPI) private var punchPointAPI
@Environment(OSSUploadService.self) private var uploadService @Environment(\.ossUploadService) private var uploadService
@Environment(ScenicSpotContext.self) private var scenicSpotContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.globalLoading) private var globalLoading @Environment(\.globalLoading) private var globalLoading
@State private var detailLoader = PunchPointListViewModel() @StateObject private var detailLoader = PunchPointListViewModel()
@State private var viewModel = PunchPointEditorViewModel() @StateObject private var viewModel = PunchPointEditorViewModel()
@State private var locationProvider = ForegroundLocationProvider() @State private var locationProvider = ForegroundLocationProvider()
@State private var selectedItems: [PhotosPickerItem] = [] @State private var selectedItems: [PhotosPickerItem] = []
@ -308,11 +308,11 @@ struct PunchPointEditorView: View {
await globalLoading.withOptionalLoading(detailLoader.selectedDetail == nil) { await globalLoading.withOptionalLoading(detailLoader.selectedDetail == nil) {
await detailLoader.loadDetail(id: punchPointId, api: punchPointAPI) await detailLoader.loadDetail(id: punchPointId, api: punchPointAPI)
if let detail = detailLoader.selectedDetail { 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) } Task { await loadPickedImages(items) }
} }
} }

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// token /// token
@MainActor @MainActor
@ -26,10 +26,9 @@ protocol ScenicQueueServing: AnyObject {
} }
@MainActor @MainActor
@Observable
/// API `/api/app/scenic-queue` /// API `/api/app/scenic-queue`
final class ScenicQueueAPI { final class ScenicQueueAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,44 +6,43 @@
// //
import Foundation import Foundation
import Observation import Combine
import Photos import Photos
import SwiftUI import SwiftUI
import UIKit import UIKit
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class QueueManagementViewModel { final class QueueManagementViewModel: ObservableObject {
var loading = false @Published var loading = false
var loadingMore = false @Published var loadingMore = false
var items: [QueueItem] = [] @Published var items: [QueueItem] = []
var scenicSpots: [ScenicSpotItem] = [] @Published var scenicSpots: [ScenicSpotItem] = []
var selectedSpotId: Int? @Published var selectedSpotId: Int?
var selectedListType: QueueListType = .queueing @Published var selectedListType: QueueListType = .queueing
var queueCount = 0 @Published var queueCount = 0
var avgWaitMin = 0.0 @Published var avgWaitMin = 0.0
var totalCount = 0 @Published var totalCount = 0
var hasMore = false @Published var hasMore = false
var page = 1 @Published var page = 1
var lastSyncTimeText = "--" @Published var lastSyncTimeText = "--"
var queueGatePassed = false @Published var queueGatePassed = false
var selectedSpotName = "--" @Published var selectedSpotName = "--"
var showStartShootingButton = true @Published var showStartShootingButton = true
var autoCallAheadCount = 0 @Published var autoCallAheadCount = 0
var quickCallButtonEnabled = false @Published var quickCallButtonEnabled = false
var prepareCallButtonEnabled = false @Published var prepareCallButtonEnabled = false
var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement? @Published var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement?
var errorMessage: String? @Published var errorMessage: String?
@ObservationIgnored private let pageSize = 10 private let pageSize = 10
@ObservationIgnored private let socketClient = ScenicQueueSocketClient() private let socketClient = ScenicQueueSocketClient()
@ObservationIgnored private var realtimeSpotId: Int? @Published private var realtimeSpotId: Int?
@ObservationIgnored private var realtimeScenicId: Int? @Published private var realtimeScenicId: Int?
@ObservationIgnored private var currentUserId: String? @Published private var currentUserId: String?
@ObservationIgnored private var currentScenicId: Int? @Published private var currentScenicId: Int?
@ObservationIgnored private var pendingRemoteCalledRecordIds = Set<Int64>() @Published private var pendingRemoteCalledRecordIds = Set<Int64>()
@ObservationIgnored private var handledRemoteCallEventIds = Set<String>() @Published private var handledRemoteCallEventIds = Set<String>()
/// ///
var pendingCount: Int { var pendingCount: Int {
@ -417,41 +416,40 @@ final class QueueManagementViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class ScenicQueueSettingsViewModel { final class ScenicQueueSettingsViewModel: ObservableObject {
var loading = false @Published var loading = false
var message: String? @Published var message: String?
var scenicSpots: [ScenicSpotItem] = [] @Published var scenicSpots: [ScenicSpotItem] = []
var selectedSpotId: Int? @Published var selectedSpotId: Int?
var photoEstimateMin = "0" @Published var photoEstimateMin = "0"
var photoEstimateSec = "30" @Published var photoEstimateSec = "30"
var firstAhead = "0" @Published var firstAhead = "0"
var firstSms = false @Published var firstSms = false
var firstPhone = false @Published var firstPhone = false
var secondAhead = "0" @Published var secondAhead = "0"
var secondSms = false @Published var secondSms = false
var secondPhone = false @Published var secondPhone = false
var broadcastIntervalSec = "50" @Published var broadcastIntervalSec = "50"
var countdownThresholdSec = "15" @Published var countdownThresholdSec = "15"
var maxQueueRangeKm = "0.00" @Published var maxQueueRangeKm = "0.00"
var localQueueLimit = "0" @Published var localQueueLimit = "0"
var localPassDelay = "1" @Published var localPassDelay = "1"
var showStartShootingButton = true @Published var showStartShootingButton = true
var autoCallAheadCount = "0" @Published var autoCallAheadCount = "0"
var queueOpen = true @Published var queueOpen = true
var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0) @Published var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0)
var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0) @Published var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0)
var customTtsText = "" @Published var customTtsText = ""
var presetVoices: [String] = [] @Published var presetVoices: [String] = []
var quickCallButtonEnabled = false @Published var quickCallButtonEnabled = false
var prepareCallButtonEnabled = false @Published var prepareCallButtonEnabled = false
var logs: [ScenicQueueSettingChangeLogItem] = [] @Published var logs: [ScenicQueueSettingChangeLogItem] = []
var qrcodeURL = "" @Published var qrcodeURL = ""
@ObservationIgnored private let speaker = ScenicQueueSpeechService() private let speaker = ScenicQueueSpeechService()
@ObservationIgnored private var currentUserId: String? @Published private var currentUserId: String?
@ObservationIgnored private var currentScenicId: Int? @Published private var currentScenicId: Int?
/// ///
func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async { func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {

View File

@ -9,12 +9,12 @@ import SwiftUI
/// ///
struct QueueManagementView: View { struct QueueManagementView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext
@Environment(ScenicQueueAPI.self) private var queueAPI @Environment(\.scenicQueueAPI) private var queueAPI
@Environment(ScenicQueueRuntime.self) private var queueRuntime @EnvironmentObject private var queueRuntime: ScenicQueueRuntime
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@State private var viewModel = QueueManagementViewModel() @StateObject private var viewModel = QueueManagementViewModel()
@State private var pendingAction: QueueActionConfirmation? @State private var pendingAction: QueueActionConfirmation?
@State private var markTarget: QueueItem? @State private var markTarget: QueueItem?
@State private var processingId: Int64? @State private var processingId: Int64?
@ -27,7 +27,7 @@ struct QueueManagementView: View {
VStack(spacing: AppMetrics.Spacing.medium) { VStack(spacing: AppMetrics.Spacing.medium) {
header header
if currentScenicId == nil { if currentScenicId == nil {
ContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文")) AppContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文"))
.frame(minHeight: 260) .frame(minHeight: 260)
} else if !viewModel.queueGatePassed { } else if !viewModel.queueGatePassed {
gateCard gateCard
@ -79,12 +79,12 @@ struct QueueManagementView: View {
.refreshable { .refreshable {
await reload() await reload()
} }
.onChange(of: viewModel.remoteCallAnnouncement) { _, announcement in .onChange(of: viewModel.remoteCallAnnouncement) { announcement in
if let announcement { if let announcement {
toastCenter.show("远端已叫号 \(announcement.queueCode.isEmpty ? "当前游客" : announcement.queueCode)") 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) } if let message { toastCenter.show(message) }
} }
} }
@ -163,7 +163,7 @@ struct QueueManagementView: View {
ProgressView("加载中...") ProgressView("加载中...")
.frame(maxWidth: .infinity, minHeight: 240) .frame(maxWidth: .infinity, minHeight: 240)
} else if viewModel.items.isEmpty { } 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) .frame(maxWidth: .infinity, minHeight: 240)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
} else { } else {

View File

@ -9,12 +9,12 @@ import SwiftUI
/// ///
struct ScenicQueueSettingsView: View { struct ScenicQueueSettingsView: View {
@Environment(AccountContext.self) private var accountContext @EnvironmentObject private var accountContext: AccountContext
@Environment(ScenicSpotContext.self) private var scenicSpotContext @EnvironmentObject private var scenicSpotContext: ScenicSpotContext
@Environment(ScenicQueueAPI.self) private var queueAPI @Environment(\.scenicQueueAPI) private var queueAPI
@Environment(ScenicQueueRuntime.self) private var queueRuntime @EnvironmentObject private var queueRuntime: ScenicQueueRuntime
@Environment(ToastCenter.self) private var toastCenter @EnvironmentObject private var toastCenter: ToastCenter
@State private var viewModel = ScenicQueueSettingsViewModel() @StateObject private var viewModel = ScenicQueueSettingsViewModel()
@State private var selectedTab: ScenicQueueSettingsTab = .basic @State private var selectedTab: ScenicQueueSettingsTab = .basic
@State private var qrcodeURL: String? @State private var qrcodeURL: String?
@State private var saving = false @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.load(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, spots: scenicSpotContext.spots)
await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId) 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) } if let message { toastCenter.show(message) }
} }
} }
@ -106,7 +106,7 @@ struct ScenicQueueSettingsView: View {
DatePicker("营业开始", selection: $viewModel.businessStartTime, displayedComponents: .hourAndMinute) DatePicker("营业开始", selection: $viewModel.businessStartTime, displayedComponents: .hourAndMinute)
DatePicker("营业结束", selection: $viewModel.businessEndTime, displayedComponents: .hourAndMinute) DatePicker("营业结束", selection: $viewModel.businessEndTime, displayedComponents: .hourAndMinute)
field("最大排队范围", text: $viewModel.maxQueueRangeKm, keyboard: .decimalPad, unit: "公里") 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.localQueueLimit, keyboard: .numberPad, unit: "")
field("过号顺延", text: $viewModel.localPassDelay, keyboard: .numberPad, unit: "") field("过号顺延", text: $viewModel.localPassDelay, keyboard: .numberPad, unit: "")
field("拍摄分钟", text: $viewModel.photoEstimateMin, keyboard: .numberPad, unit: "") field("拍摄分钟", text: $viewModel.photoEstimateMin, keyboard: .numberPad, unit: "")
@ -192,7 +192,7 @@ struct ScenicQueueSettingsView: View {
} }
} }
if viewModel.logs.isEmpty { if viewModel.logs.isEmpty {
ContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath") AppContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath")
.frame(maxWidth: .infinity, minHeight: 180) .frame(maxWidth: .infinity, minHeight: 180)
} else { } else {
ForEach(viewModel.logs) { log in ForEach(viewModel.logs) { log in

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
@MainActor @MainActor
@ -34,10 +34,9 @@ protocol ScenicPermissionServing {
} }
@MainActor @MainActor
@Observable
/// API /// API
final class ScenicPermissionAPI { final class ScenicPermissionAPI {
@ObservationIgnored private let client: APIClient private let client: APIClient
/// API /// API
init(client: APIClient) { init(client: APIClient) {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// ///
struct PermissionRoleOption: Equatable, Identifiable { struct PermissionRoleOption: Equatable, Identifiable {
@ -31,21 +31,20 @@ struct ExistingRolePermissionInfo: Equatable, Identifiable {
} }
@MainActor @MainActor
@Observable
/// ViewModel/ /// ViewModel/
final class PermissionApplyViewModel { final class PermissionApplyViewModel: ObservableObject {
var roleOptions: [PermissionRoleOption] = [] @Published var roleOptions: [PermissionRoleOption] = []
var selectedRoleId: Int? @Published var selectedRoleId: Int?
var scenicOptions: [PermissionScenicOption] = [] @Published var scenicOptions: [PermissionScenicOption] = []
var loadingScenics = false @Published var loadingScenics = false
var scenicLoadFailed = false @Published var scenicLoadFailed = false
var scenicLoadFailureReason: String? @Published var scenicLoadFailureReason: String?
var submitting = false @Published var submitting = false
var message: String? @Published var message: String?
private let initialPending: RoleApplyPendingResponse? private let initialPending: RoleApplyPendingResponse?
private var rolePermissions: [RolePermissionResponse] = [] @Published private var rolePermissions: [RolePermissionResponse] = []
private var selectedIds: Set<Int> = [] @Published private var selectedIds: Set<Int> = []
/// ViewModel /// ViewModel
init(initialPending: RoleApplyPendingResponse? = nil) { init(initialPending: RoleApplyPendingResponse? = nil) {
@ -181,13 +180,12 @@ final class PermissionApplyViewModel {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class PermissionApplyStatusViewModel { final class PermissionApplyStatusViewModel: ObservableObject {
var loading = false @Published var loading = false
var pending: RoleApplyPendingResponse? @Published var pending: RoleApplyPendingResponse?
var loadFailed = false @Published var loadFailed = false
var loadFailureReason: String? @Published var loadFailureReason: String?
/// ///
func load(api: any ScenicPermissionServing, applyCode: String?) async { func load(api: any ScenicPermissionServing, applyCode: String?) async {

View File

@ -6,7 +6,7 @@
// //
import Foundation import Foundation
import Observation import Combine
/// 稿 /// 稿
struct ScenicApplicationImageDraft: Equatable, Identifiable { struct ScenicApplicationImageDraft: Equatable, Identifiable {
@ -29,27 +29,26 @@ struct ScenicApplicationImageDraft: Equatable, Identifiable {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class ScenicApplicationViewModel { final class ScenicApplicationViewModel: ObservableObject {
var scenicName = "" @Published var scenicName = ""
var imageURLs = "" @Published var imageURLs = ""
var localImages: [ScenicApplicationImageDraft] = [] @Published var localImages: [ScenicApplicationImageDraft] = []
var selectedProvince = "" @Published var selectedProvince = ""
var selectedCity = "" @Published var selectedCity = ""
var coopType = 1 @Published var coopType = 1
var remark = "" @Published var remark = ""
var agreed = false @Published var agreed = false
var submitting = false @Published var submitting = false
var message: String? @Published var message: String?
var provinces: [ScenicAreaNode] = [] @Published var provinces: [ScenicAreaNode] = []
var cities: [ScenicAreaNode] = [] @Published var cities: [ScenicAreaNode] = []
var pending: ScenicApplicationPendingResponse? @Published var pending: ScenicApplicationPendingResponse?
var loading = false @Published var loading = false
var loadFailed = false @Published var loadFailed = false
var loadFailureReason: String? @Published var loadFailureReason: String?
var pendingLoadFailed = false @Published var pendingLoadFailed = false
var pendingLoadFailureReason: String? @Published var pendingLoadFailureReason: String?
/// ///
var isReadOnly: Bool { var isReadOnly: Bool {

View File

@ -7,7 +7,7 @@
import CoreLocation import CoreLocation
import Foundation import Foundation
import Observation import Combine
/// ///
struct ScenicSelectionItem: Equatable, Identifiable { struct ScenicSelectionItem: Equatable, Identifiable {
@ -51,13 +51,12 @@ struct ScenicSelectionItem: Equatable, Identifiable {
} }
@MainActor @MainActor
@Observable
/// ViewModel /// ViewModel
final class ScenicSelectionViewModel { final class ScenicSelectionViewModel: ObservableObject {
var searchQuery = "" @Published var searchQuery = ""
var currentLocationText = "定位后获取最近景区" @Published var currentLocationText = "定位后获取最近景区"
var locationWarning: String? @Published var locationWarning: String?
private(set) var items: [ScenicSelectionItem] = [] @Published private(set) var items: [ScenicSelectionItem] = []
/// ///
var filteredItems: [ScenicSelectionItem] { var filteredItems: [ScenicSelectionItem] {

Some files were not shown because too many files have changed in this diff Show More