从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
337
iOS16兼容迁移记录.md
Normal file
337
iOS16兼容迁移记录.md
Normal 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`。
|
||||
@ -22,6 +22,13 @@
|
||||
remoteGlobalIDString = 939AC7952FE3F832004B22E4;
|
||||
remoteInfo = suixinkan;
|
||||
};
|
||||
B00000072FEF000000000007 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 939AC78E2FE3F832004B22E4 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 939AC7952FE3F832004B22E4;
|
||||
remoteInfo = suixinkan;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@ -30,6 +37,7 @@
|
||||
93946BFAF78A845957AF2518 /* Pods-suixinkan.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan.debug.xcconfig"; path = "Target Support Files/Pods-suixinkan/Pods-suixinkan.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
939AC7962FE3F832004B22E4 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A00000012FE9000000000001 /* suixinkanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B00000012FEF000000000001 /* suixinkanUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D763A7B62050979A1482C545 /* Pods-suixinkanTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkanTests.debug.xcconfig"; path = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.debug.xcconfig"; sourceTree = "<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; };
|
||||
@ -59,6 +67,11 @@
|
||||
path = suixinkanTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = suixinkanUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -81,6 +94,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B00000052FEF000000000005 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@ -89,6 +109,7 @@
|
||||
children = (
|
||||
939AC7982FE3F832004B22E4 /* suixinkan */,
|
||||
A00000022FE9000000000002 /* suixinkanTests */,
|
||||
B00000022FEF000000000002 /* suixinkanUITests */,
|
||||
939AC7972FE3F832004B22E4 /* Products */,
|
||||
C7EEB11765222926FB482A0E /* Pods */,
|
||||
F6ABC23A28D8DF8126F325ED /* Frameworks */,
|
||||
@ -100,6 +121,7 @@
|
||||
children = (
|
||||
939AC7962FE3F832004B22E4 /* suixinkan.app */,
|
||||
A00000012FE9000000000001 /* suixinkanTests.xctest */,
|
||||
B00000012FEF000000000001 /* suixinkanUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@ -176,6 +198,27 @@
|
||||
productReference = A00000012FE9000000000001 /* suixinkanTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
B00000032FEF000000000003 /* suixinkanUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B000000B2FEF00000000000B /* Build configuration list for PBXNativeTarget "suixinkanUITests" */;
|
||||
buildPhases = (
|
||||
B00000042FEF000000000004 /* Sources */,
|
||||
B00000052FEF000000000005 /* Frameworks */,
|
||||
B00000062FEF000000000006 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B00000082FEF000000000008 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
B00000022FEF000000000002 /* suixinkanUITests */,
|
||||
);
|
||||
name = suixinkanUITests;
|
||||
productName = suixinkanUITests;
|
||||
productReference = B00000012FEF000000000001 /* suixinkanUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@ -193,6 +236,10 @@
|
||||
CreatedOnToolsVersion = 26.5;
|
||||
TestTargetID = 939AC7952FE3F832004B22E4;
|
||||
};
|
||||
B00000032FEF000000000003 = {
|
||||
CreatedOnToolsVersion = 26.5;
|
||||
TestTargetID = 939AC7952FE3F832004B22E4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 939AC7912FE3F832004B22E4 /* Build configuration list for PBXProject "suixinkan" */;
|
||||
@ -216,6 +263,7 @@
|
||||
targets = (
|
||||
939AC7952FE3F832004B22E4 /* suixinkan */,
|
||||
A00000032FE9000000000003 /* suixinkanTests */,
|
||||
B00000032FEF000000000003 /* suixinkanUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@ -235,6 +283,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B00000062FEF000000000006 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
@ -320,6 +375,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B00000042FEF000000000004 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@ -328,6 +390,11 @@
|
||||
target = 939AC7952FE3F832004B22E4 /* suixinkan */;
|
||||
targetProxy = A00000072FE9000000000007 /* PBXContainerItemProxy */;
|
||||
};
|
||||
B00000082FEF000000000008 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 939AC7952FE3F832004B22E4 /* suixinkan */;
|
||||
targetProxy = B00000072FEF000000000007 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@ -467,7 +534,7 @@
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = suixinkan/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -505,7 +572,7 @@
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = suixinkan/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -539,7 +606,7 @@
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -575,7 +642,7 @@
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -600,6 +667,72 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B00000092FEF000000000009 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TEST_TARGET_NAME = suixinkan;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B000000A2FEF00000000000A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TEST_TARGET_NAME = suixinkan;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@ -630,6 +763,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
B000000B2FEF00000000000B /* Build configuration list for PBXNativeTarget "suixinkanUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B00000092FEF000000000009 /* Debug */,
|
||||
B000000A2FEF00000000000A /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "40830d6592be5c997cf1b8848d6a8791afd48ce9a5e48a1f297fbae4a6de3783",
|
||||
"originHash" : "6f02892b99a60872df0561ceead8949f780d607eccd0a776d8f299248fc13a72",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "alibabacloud-oss-swift-sdk-v2",
|
||||
@ -19,6 +19,15 @@
|
||||
"version" : "8.10.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "lottie-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/airbnb/lottie-ios.git",
|
||||
"state" : {
|
||||
"revision" : "f4db77d7feacba0c2360b84a40c38a6ce8ff399d",
|
||||
"version" : "4.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@ -35,6 +35,20 @@
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</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>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
@ -64,6 +78,16 @@
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "B00000032FEF000000000003"
|
||||
BuildableName = "suixinkanUITests.xctest"
|
||||
BlueprintName = "suixinkanUITests"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
|
||||
261
suixinkan/App/AppServiceEnvironment.swift
Normal file
261
suixinkan/App/AppServiceEnvironment.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
22
suixinkan/App/AppUITestLaunchState.swift
Normal file
22
suixinkan/App/AppUITestLaunchState.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
31
suixinkan/App/Navigation/NavigationCompatibility.swift
Normal file
31
suixinkan/App/Navigation/NavigationCompatibility.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Observation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// 应用内可导航目标,承载各 Tab 内部的路由目的地。
|
||||
@ -60,10 +60,9 @@ extension OrdersRoute {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 单个 NavigationStack 的路径容器,用于保存某个 Tab 的导航历史。
|
||||
final class RouterPath {
|
||||
var path: [AppRoute] = []
|
||||
final class RouterPath: ObservableObject {
|
||||
@Published var path: [AppRoute] = []
|
||||
|
||||
/// 将指定路由压入当前 Tab 的导航栈。
|
||||
func navigate(to route: AppRoute) {
|
||||
@ -77,13 +76,12 @@ final class RouterPath {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 主导航状态中心,管理当前 Tab 和每个 Tab 独立的 NavigationStack 路径。
|
||||
final class AppRouter {
|
||||
var selectedTab: AppTab = .home
|
||||
var selectedOrdersEntry: OrdersEntry = .storeOrders
|
||||
private(set) var pendingOrderScanCode: String?
|
||||
private var routers: [AppTab: RouterPath] = [:]
|
||||
final class AppRouter: ObservableObject {
|
||||
@Published var selectedTab: AppTab = .home
|
||||
@Published var selectedOrdersEntry: OrdersEntry = .storeOrders
|
||||
@Published private(set) var pendingOrderScanCode: String?
|
||||
@Published private var routers: [AppTab: RouterPath] = [:]
|
||||
|
||||
/// 获取指定 Tab 对应的路由路径容器,不存在时自动创建。
|
||||
func router(for tab: AppTab) -> RouterPath {
|
||||
|
||||
@ -10,12 +10,12 @@ import SwiftUI
|
||||
/// App 根视图,负责创建全局依赖并根据登录状态切换登录页和主界面。
|
||||
struct RootView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var appSession = AppSession()
|
||||
@State private var accountContext = AccountContext()
|
||||
@State private var permissionContext = PermissionContext()
|
||||
@State private var scenicSpotContext = ScenicSpotContext()
|
||||
@State private var appRouter = AppRouter()
|
||||
@State private var toastCenter = ToastCenter()
|
||||
@StateObject private var appSession = AppSession()
|
||||
@StateObject private var accountContext = AccountContext()
|
||||
@StateObject private var permissionContext = PermissionContext()
|
||||
@StateObject private var scenicSpotContext = ScenicSpotContext()
|
||||
@StateObject private var appRouter = AppRouter()
|
||||
@StateObject private var toastCenter = ToastCenter()
|
||||
@State private var globalLoading = GlobalLoadingCenter()
|
||||
@State private var snapshotStore: AccountSnapshotStore
|
||||
@State private var apiClient: APIClient
|
||||
@ -33,7 +33,7 @@ struct RootView: View {
|
||||
@State private var scenicSettlementAPI: ScenicSettlementAPI
|
||||
@State private var messageCenterAPI: MessageCenterAPI
|
||||
@State private var scenicQueueAPI: ScenicQueueAPI
|
||||
@State private var scenicQueueRuntime = ScenicQueueRuntime()
|
||||
@StateObject private var scenicQueueRuntime = ScenicQueueRuntime()
|
||||
@State private var liveAPI: LiveAPI
|
||||
@State private var operatingAreaAPI: OperatingAreaAPI
|
||||
@State private var pilotCertificationAPI: PilotCertificationAPI
|
||||
@ -98,41 +98,41 @@ struct RootView: View {
|
||||
rootContent
|
||||
.globalToastOverlay()
|
||||
.globalLoadingOverlay(loadingCenter: globalLoading)
|
||||
.environment(appSession)
|
||||
.environment(accountContext)
|
||||
.environment(permissionContext)
|
||||
.environment(scenicSpotContext)
|
||||
.environment(appRouter)
|
||||
.environment(toastCenter)
|
||||
.environmentObject(appSession)
|
||||
.environmentObject(accountContext)
|
||||
.environmentObject(permissionContext)
|
||||
.environmentObject(scenicSpotContext)
|
||||
.environmentObject(appRouter)
|
||||
.environmentObject(toastCenter)
|
||||
.environment(\.globalLoading, globalLoading)
|
||||
.environment(snapshotStore)
|
||||
.environment(apiClient)
|
||||
.environment(authAPI)
|
||||
.environment(profileAPI)
|
||||
.environment(uploadAPI)
|
||||
.environment(ossUploadService)
|
||||
.environment(accountContextAPI)
|
||||
.environment(ordersAPI)
|
||||
.environment(statisticsAPI)
|
||||
.environment(paymentAPI)
|
||||
.environment(walletAPI)
|
||||
.environment(pushAPI)
|
||||
.environment(scenicPermissionAPI)
|
||||
.environment(scenicSettlementAPI)
|
||||
.environment(messageCenterAPI)
|
||||
.environment(scenicQueueAPI)
|
||||
.environment(scenicQueueRuntime)
|
||||
.environment(liveAPI)
|
||||
.environment(operatingAreaAPI)
|
||||
.environment(pilotCertificationAPI)
|
||||
.environment(taskAPI)
|
||||
.environment(projectAPI)
|
||||
.environment(scheduleAPI)
|
||||
.environment(inviteAPI)
|
||||
.environment(assetsAPI)
|
||||
.environment(punchPointAPI)
|
||||
.environment(locationReportAPI)
|
||||
.environment(authSessionCoordinator)
|
||||
.environment(\.accountSnapshotStore, snapshotStore)
|
||||
.environment(\.apiClient, apiClient)
|
||||
.environment(\.authAPI, authAPI)
|
||||
.environment(\.profileAPI, profileAPI)
|
||||
.environment(\.uploadAPI, uploadAPI)
|
||||
.environment(\.ossUploadService, ossUploadService)
|
||||
.environment(\.accountContextAPI, accountContextAPI)
|
||||
.environment(\.ordersAPI, ordersAPI)
|
||||
.environment(\.statisticsAPI, statisticsAPI)
|
||||
.environment(\.paymentAPI, paymentAPI)
|
||||
.environment(\.walletAPI, walletAPI)
|
||||
.environment(\.pushAPI, pushAPI)
|
||||
.environment(\.scenicPermissionAPI, scenicPermissionAPI)
|
||||
.environment(\.scenicSettlementAPI, scenicSettlementAPI)
|
||||
.environment(\.messageCenterAPI, messageCenterAPI)
|
||||
.environment(\.scenicQueueAPI, scenicQueueAPI)
|
||||
.environmentObject(scenicQueueRuntime)
|
||||
.environment(\.liveAPI, liveAPI)
|
||||
.environment(\.operatingAreaAPI, operatingAreaAPI)
|
||||
.environment(\.pilotCertificationAPI, pilotCertificationAPI)
|
||||
.environment(\.taskAPI, taskAPI)
|
||||
.environment(\.projectAPI, projectAPI)
|
||||
.environment(\.scheduleAPI, scheduleAPI)
|
||||
.environment(\.inviteAPI, inviteAPI)
|
||||
.environment(\.assetsAPI, assetsAPI)
|
||||
.environment(\.punchPointAPI, punchPointAPI)
|
||||
.environment(\.locationReportAPI, locationReportAPI)
|
||||
.environment(\.authSessionCoordinator, authSessionCoordinator)
|
||||
.task {
|
||||
apiClient.bindAuthTokenProvider { appSession.token }
|
||||
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
||||
@ -160,7 +160,7 @@ struct RootView: View {
|
||||
api: accountContextAPI
|
||||
)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
if appSession.isLoggedIn {
|
||||
scenicQueueRuntime.update(
|
||||
api: scenicQueueAPI,
|
||||
@ -172,7 +172,7 @@ struct RootView: View {
|
||||
scenicQueueRuntime.stop()
|
||||
}
|
||||
}
|
||||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||||
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||||
if appSession.isLoggedIn {
|
||||
scenicQueueRuntime.update(
|
||||
api: scenicQueueAPI,
|
||||
@ -182,7 +182,7 @@ struct RootView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.onChange(of: appSession.phase) { _, phase in
|
||||
.onChange(of: appSession.phase) { phase in
|
||||
switch phase {
|
||||
case .loggedIn:
|
||||
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 当前账号的基础资料实体,用于跨页面展示昵称、手机号和头像。
|
||||
struct AccountProfile: Codable, Equatable {
|
||||
@ -58,14 +58,13 @@ struct BusinessScope: Codable, Equatable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 账号上下文状态中心,保存当前账号资料和景区/门店作用域。
|
||||
final class AccountContext {
|
||||
private(set) var profile: AccountProfile?
|
||||
private(set) var scenicScopes: [BusinessScope] = []
|
||||
private(set) var storeScopes: [BusinessScope] = []
|
||||
var currentScenic: BusinessScope?
|
||||
var currentStore: BusinessScope?
|
||||
final class AccountContext: ObservableObject {
|
||||
@Published private(set) var profile: AccountProfile?
|
||||
@Published private(set) var scenicScopes: [BusinessScope] = []
|
||||
@Published private(set) var storeScopes: [BusinessScope] = []
|
||||
@Published var currentScenic: BusinessScope?
|
||||
@Published var currentStore: BusinessScope?
|
||||
|
||||
/// 应用登录成功后写入账号资料。
|
||||
func applyLogin(profile: AccountProfile? = nil) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 登录阶段实体,表示根视图当前应该展示的认证状态。
|
||||
enum AuthPhase: Equatable {
|
||||
@ -16,11 +16,10 @@ enum AuthPhase: Equatable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录会话状态中心,保存 token 和当前认证阶段。
|
||||
final class AppSession {
|
||||
private(set) var phase: AuthPhase = .loggedOut
|
||||
private(set) var token: String?
|
||||
final class AppSession: ObservableObject {
|
||||
@Published private(set) var phase: AuthPhase = .loggedOut
|
||||
@Published private(set) var token: String?
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
phase == .loggedIn
|
||||
|
||||
@ -6,15 +6,14 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录会话协调器,统一处理登录完成、退出登录和缓存同步。
|
||||
final class AuthSessionCoordinator {
|
||||
@ObservationIgnored private let tokenStore: SessionTokenStore
|
||||
@ObservationIgnored private let snapshotStore: AccountSnapshotStore
|
||||
@ObservationIgnored private let preferencesStore: AppPreferencesStore
|
||||
private let tokenStore: SessionTokenStore
|
||||
private let snapshotStore: AccountSnapshotStore
|
||||
private let preferencesStore: AppPreferencesStore
|
||||
|
||||
/// 初始化登录会话协调器,并注入 token、账号快照和偏好存储。
|
||||
init(
|
||||
|
||||
@ -6,15 +6,14 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 权限上下文状态中心,保存角色权限、当前角色和扁平化权限 URI。
|
||||
final class PermissionContext {
|
||||
private(set) var rolePermissions: [RolePermissionResponse] = []
|
||||
private(set) var permissionURIs: Set<String> = []
|
||||
var currentRole: RoleInfo?
|
||||
final class PermissionContext: ObservableObject {
|
||||
@Published private(set) var rolePermissions: [RolePermissionResponse] = []
|
||||
@Published private(set) var permissionURIs: Set<String> = []
|
||||
@Published var currentRole: RoleInfo?
|
||||
|
||||
/// 替换角色权限列表,并尽量按缓存角色 ID 保持当前角色。
|
||||
func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 景点加载阶段实体,表示当前景区景点列表的加载状态。
|
||||
enum ScenicSpotLoadState: Equatable {
|
||||
@ -17,12 +17,11 @@ enum ScenicSpotLoadState: Equatable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景点上下文状态中心,保存当前景区下的景点或打卡点列表。
|
||||
final class ScenicSpotContext {
|
||||
private(set) var scenicId: Int?
|
||||
private(set) var spots: [ScenicSpotItem] = []
|
||||
private(set) var loadState: ScenicSpotLoadState = .idle
|
||||
final class ScenicSpotContext: ObservableObject {
|
||||
@Published private(set) var scenicId: Int?
|
||||
@Published private(set) var spots: [ScenicSpotItem] = []
|
||||
@Published private(set) var loadState: ScenicSpotLoadState = .idle
|
||||
|
||||
/// 按景区 ID 重新加载景点列表,失败只影响景点模块本身。
|
||||
func reload(scenicId: Int?, api: AccountContextServing) async {
|
||||
|
||||
@ -6,14 +6,13 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录态启动恢复器,负责冷启动时从缓存恢复并校验 token。
|
||||
final class SessionBootstrapper {
|
||||
@ObservationIgnored private let tokenStore: SessionTokenStore
|
||||
@ObservationIgnored private let snapshotStore: AccountSnapshotStore
|
||||
private let tokenStore: SessionTokenStore
|
||||
private let snapshotStore: AccountSnapshotStore
|
||||
private var didAttemptRestore = false
|
||||
|
||||
/// 初始化启动恢复器,并注入 token 与账号快照存储。
|
||||
|
||||
@ -5,17 +5,16 @@
|
||||
// Created by Codex on 2026/6/18.
|
||||
//
|
||||
|
||||
import Observation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 全局 Toast 状态中心,负责保存和清除当前提示文案。
|
||||
final class ToastCenter {
|
||||
fileprivate var message: String?
|
||||
@ObservationIgnored private let autoDismissNanoseconds: UInt64
|
||||
@ObservationIgnored private var dismissTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var displayToken: UInt64 = 0
|
||||
final class ToastCenter: ObservableObject {
|
||||
@Published fileprivate var message: String?
|
||||
private let autoDismissNanoseconds: UInt64
|
||||
@Published private var dismissTask: Task<Void, Never>?
|
||||
@Published private var displayToken: UInt64 = 0
|
||||
|
||||
/// 创建 Toast 状态中心,默认 2.2 秒后自动隐藏。
|
||||
init(autoDismissNanoseconds: UInt64 = 2_200_000_000) {
|
||||
@ -65,7 +64,7 @@ final class ToastCenter {
|
||||
|
||||
/// 全局 Toast 叠加层修饰器,负责把 Toast 展示到页面顶部。
|
||||
private struct GlobalToastOverlay: ViewModifier {
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
|
||||
@ -12,6 +12,10 @@ import SwiftUI
|
||||
struct suixinkanApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
init() {
|
||||
AppUITestLaunchState.resetIfNeeded()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
|
||||
75
suixinkan/Core/Design/AppContentUnavailableView.swift
Normal file
75
suixinkan/Core/Design/AppContentUnavailableView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,7 +77,7 @@ final class GlobalLoadingCenter {
|
||||
fileprivate final class GlobalLoadingState: ObservableObject {
|
||||
@Published fileprivate var isVisible = false
|
||||
@Published fileprivate var message = ""
|
||||
fileprivate var activeCount = 0
|
||||
@Published fileprivate var activeCount = 0
|
||||
}
|
||||
|
||||
/// Lottie Loading 动画容器,负责播放主包中的 loading.json。
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 前台定位结果实体,表示一次即时定位得到的坐标和可展示地址。
|
||||
struct ForegroundLocationResult: Equatable {
|
||||
@ -18,11 +18,10 @@ struct ForegroundLocationResult: Equatable {
|
||||
|
||||
/// 前台定位 Provider,只负责当前页面主动请求位置,不缓存、不后台持续定位。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ForegroundLocationProvider: NSObject {
|
||||
@ObservationIgnored private let manager = CLLocationManager()
|
||||
@ObservationIgnored private let geocoder = CLGeocoder()
|
||||
@ObservationIgnored private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
|
||||
final class ForegroundLocationProvider: NSObject, ObservableObject {
|
||||
private let manager = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
@Published private var continuation: CheckedContinuation<ForegroundLocationResult, Error>?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// URLSession 抽象协议,用于让网络客户端支持测试替身。
|
||||
protocol URLSessionProtocol {
|
||||
@ -17,13 +17,12 @@ protocol URLSessionProtocol {
|
||||
extension URLSession: URLSessionProtocol {}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 统一网络请求客户端,负责构造请求、注入 token、校验响应和解析业务 Envelope。
|
||||
final class APIClient {
|
||||
@ObservationIgnored private let session: URLSessionProtocol
|
||||
@ObservationIgnored private let encoder: JSONEncoder
|
||||
@ObservationIgnored private let decoder: JSONDecoder
|
||||
@ObservationIgnored private var authTokenProvider: (() -> String?)?
|
||||
private let session: URLSessionProtocol
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private var authTokenProvider: (() -> String?)?
|
||||
|
||||
private let environment: APIEnvironment
|
||||
private let appVersion: String
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 推送 API,负责把 iOS APNs token 上报到当前后端兼容接口。
|
||||
final class PushAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化推送 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,31 +6,30 @@
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Observation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队运行时,页面外按开关短轮询当前打卡点并语音播报队列变化。
|
||||
final class ScenicQueueRuntime {
|
||||
private(set) var isMonitoring = false
|
||||
private(set) var lastPollText = "--"
|
||||
private(set) var lastSpokenText = ""
|
||||
private(set) var lastQueueCount = 0
|
||||
private(set) var lastError: String?
|
||||
final class ScenicQueueRuntime: ObservableObject {
|
||||
@Published private(set) var isMonitoring = false
|
||||
@Published private(set) var lastPollText = "--"
|
||||
@Published private(set) var lastSpokenText = ""
|
||||
@Published private(set) var lastQueueCount = 0
|
||||
@Published private(set) var lastError: String?
|
||||
|
||||
@ObservationIgnored private let speaker = ScenicQueueSpeechService()
|
||||
@ObservationIgnored private weak var api: (any ScenicQueueServing)?
|
||||
@ObservationIgnored private var userId: String?
|
||||
@ObservationIgnored private var scenicId: Int?
|
||||
@ObservationIgnored private var scenePhase: ScenePhase = .active
|
||||
@ObservationIgnored private var pollTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
|
||||
@ObservationIgnored private var announcementState = ScenicQueueAnnouncementState()
|
||||
@ObservationIgnored private var lastScenicId: Int?
|
||||
@ObservationIgnored private var lastSpotId: Int?
|
||||
@ObservationIgnored private var suspendedByQueueScreen = false
|
||||
private let speaker = ScenicQueueSpeechService()
|
||||
private weak var api: (any ScenicQueueServing)?
|
||||
@Published private var userId: String?
|
||||
@Published private var scenicId: Int?
|
||||
@Published private var scenePhase: ScenePhase = .active
|
||||
@Published private var pollTask: Task<Void, Never>?
|
||||
@Published private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid
|
||||
@Published private var announcementState = ScenicQueueAnnouncementState()
|
||||
@Published private var lastScenicId: Int?
|
||||
@Published private var lastSpotId: Int?
|
||||
@Published private var suspendedByQueueScreen = false
|
||||
|
||||
/// 语音播报是否开启。
|
||||
var voiceEnabled: Bool {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 账号缓存快照实体,保存可重建、非敏感的账号展示和业务上下文。
|
||||
struct AccountSnapshot: Codable, Equatable {
|
||||
@ -41,13 +41,12 @@ struct AccountSnapshot: Codable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
/// 账号快照存储服务,使用 UserDefaults 保存非敏感登录上下文。
|
||||
final class AccountSnapshotStore {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let key: String
|
||||
@ObservationIgnored private let encoder: JSONEncoder
|
||||
@ObservationIgnored private let decoder: JSONDecoder
|
||||
private let defaults: UserDefaults
|
||||
private let key: String
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
/// 初始化账号快照存储服务,并允许测试注入独立的 UserDefaults。
|
||||
init(
|
||||
|
||||
@ -6,14 +6,13 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@Observable
|
||||
/// App 偏好存储服务,保存上次手机号和协议状态等非敏感设置。
|
||||
final class AppPreferencesStore {
|
||||
@ObservationIgnored private let defaults: UserDefaults
|
||||
@ObservationIgnored private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
|
||||
@ObservationIgnored private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
|
||||
private let defaults: UserDefaults
|
||||
private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username"
|
||||
private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted"
|
||||
|
||||
/// 初始化偏好存储服务,并允许测试注入独立的 UserDefaults。
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@ -43,4 +42,10 @@ final class AppPreferencesStore {
|
||||
func loadPrivacyAgreementAccepted() -> Bool {
|
||||
defaults.bool(forKey: privacyAgreementAcceptedKey)
|
||||
}
|
||||
|
||||
/// 清空登录页偏好。
|
||||
func clear() {
|
||||
defaults.removeObject(forKey: lastLoginUsernameKey)
|
||||
defaults.removeObject(forKey: privacyAgreementAcceptedKey)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import Security
|
||||
|
||||
/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。
|
||||
@ -21,11 +21,10 @@ enum SessionTokenStoreError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。
|
||||
final class SessionTokenStore {
|
||||
@ObservationIgnored private let service: String
|
||||
@ObservationIgnored private let account: String
|
||||
private let service: String
|
||||
private let account: String
|
||||
|
||||
/// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。
|
||||
init(
|
||||
|
||||
@ -40,7 +40,7 @@ struct RemoteImage<Placeholder: View>: View {
|
||||
placeholder()
|
||||
}
|
||||
}
|
||||
.onChange(of: normalizedURLString) { _, _ in
|
||||
.onChange(of: normalizedURLString) { _ in
|
||||
didFail = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
#if canImport(AlibabaCloudOSS)
|
||||
@ -51,10 +51,9 @@ protocol OSSUploadServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。
|
||||
final class OSSUploadService {
|
||||
@ObservationIgnored private let configService: any OSSConfigServing
|
||||
private let configService: any OSSConfigServing
|
||||
|
||||
/// 初始化 OSS 上传服务,并注入 STS 配置服务。
|
||||
init(configService: any OSSConfigServing) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// OSS 配置服务协议,抽象 STS token 获取能力,便于上传服务测试替换。
|
||||
@MainActor
|
||||
@ -16,10 +16,9 @@ protocol OSSConfigServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 上传 API,封装文件上传前需要的服务端配置接口。
|
||||
final class UploadAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化上传 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
/// 账号上下文服务协议,抽象权限、景区、门店和景点读取能力以便测试替换。
|
||||
@ -25,10 +25,9 @@ protocol AccountContextServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 账号上下文 API,封装角色权限、景区、门店和景点读取接口。
|
||||
final class AccountContextAPI: AccountContextServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化账号上下文 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 资产服务协议,抽象云盘、媒体库和相册接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
@ -83,9 +83,8 @@ protocol AssetsServing {
|
||||
|
||||
/// 资产 API,负责封装相册云盘和素材管理相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AssetsAPI: AssetsServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化资产 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,15 +6,14 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CloudTransferStore {
|
||||
final class CloudTransferStore: ObservableObject {
|
||||
static let shared = CloudTransferStore()
|
||||
|
||||
private(set) var records: [CloudTransferItem] = []
|
||||
@Published private(set) var records: [CloudTransferItem] = []
|
||||
|
||||
/// 新增一条传输记录并返回记录 ID。
|
||||
@discardableResult
|
||||
@ -56,19 +55,18 @@ final class CloudTransferStore {
|
||||
|
||||
/// 云盘 ViewModel,负责文件列表、筛选、分页、文件操作和上传闭环。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class CloudStorageViewModel {
|
||||
var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||||
var files: [CloudDriveFile] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var searchText = ""
|
||||
var selectedFilter: CloudDriveFilter = .all
|
||||
var selectedSort: CloudDriveSort = .updatedDesc
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var isMutating = false
|
||||
var errorMessage: String?
|
||||
final class CloudStorageViewModel: ObservableObject {
|
||||
@Published var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")]
|
||||
@Published var files: [CloudDriveFile] = []
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var searchText = ""
|
||||
@Published var selectedFilter: CloudDriveFilter = .all
|
||||
@Published var selectedSort: CloudDriveSort = .updatedDesc
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var isMutating = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
@ -260,20 +258,19 @@ final class CloudStorageViewModel {
|
||||
|
||||
/// 媒体库 ViewModel,负责素材或样片列表、筛选、分页、详情和上下架。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MediaLibraryViewModel {
|
||||
final class MediaLibraryViewModel: ObservableObject {
|
||||
let kind: MediaLibraryKind
|
||||
var items: [MediaLibraryItem] = []
|
||||
var selectedDetail: MediaLibraryDetail?
|
||||
var orderInfo: MediaLibraryOrderInfo?
|
||||
var total = 0
|
||||
var page = 1
|
||||
var keyword = ""
|
||||
var selectedAuditFilter: MediaLibraryAuditFilter = .all
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var isLoadingDetail = false
|
||||
var errorMessage: String?
|
||||
@Published var items: [MediaLibraryItem] = []
|
||||
@Published var selectedDetail: MediaLibraryDetail?
|
||||
@Published var orderInfo: MediaLibraryOrderInfo?
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var keyword = ""
|
||||
@Published var selectedAuditFilter: MediaLibraryAuditFilter = .all
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var isLoadingDetail = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
@ -396,20 +393,19 @@ final class MediaLibraryViewModel {
|
||||
|
||||
/// 媒体库上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材或样片。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class MediaLibraryEditorViewModel {
|
||||
var name = ""
|
||||
var description = ""
|
||||
var selectedSpotId: Int?
|
||||
var selectedProjectId: Int?
|
||||
var projects: [PhotographerProjectItem] = []
|
||||
var tagsText = ""
|
||||
var coverFile: MediaLocalUploadFile?
|
||||
var mediaFiles: [MediaLocalUploadFile] = []
|
||||
var isSubmitting = false
|
||||
var isLoadingProjects = false
|
||||
var errorMessage: String?
|
||||
var didSubmitSuccessfully = false
|
||||
final class MediaLibraryEditorViewModel: ObservableObject {
|
||||
@Published var name = ""
|
||||
@Published var description = ""
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published var selectedProjectId: Int?
|
||||
@Published var projects: [PhotographerProjectItem] = []
|
||||
@Published var tagsText = ""
|
||||
@Published var coverFile: MediaLocalUploadFile?
|
||||
@Published var mediaFiles: [MediaLocalUploadFile] = []
|
||||
@Published var isSubmitting = false
|
||||
@Published var isLoadingProjects = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var didSubmitSuccessfully = false
|
||||
|
||||
private let editingId: Int?
|
||||
|
||||
@ -611,18 +607,17 @@ final class MediaLibraryEditorViewModel {
|
||||
|
||||
/// 相册列表 ViewModel,负责相册文件夹搜索、日期筛选、分页和新建相册。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AlbumListViewModel {
|
||||
var folders: [AlbumFolderItem] = []
|
||||
var total = 0
|
||||
var page = 1
|
||||
var searchText = ""
|
||||
var startTime = ""
|
||||
var endTime = ""
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var isMutating = false
|
||||
var errorMessage: String?
|
||||
final class AlbumListViewModel: ObservableObject {
|
||||
@Published var folders: [AlbumFolderItem] = []
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var searchText = ""
|
||||
@Published var startTime = ""
|
||||
@Published var endTime = ""
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var isMutating = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
@ -716,18 +711,17 @@ final class AlbumListViewModel {
|
||||
|
||||
/// 相册详情 ViewModel,负责相册信息、图片/视频列表和文件操作。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AlbumDetailViewModel {
|
||||
final class AlbumDetailViewModel: ObservableObject {
|
||||
let folderId: Int
|
||||
var folder: AlbumFolderItem?
|
||||
var files: [AlbumFileItem] = []
|
||||
var selectedTab: AlbumFileTab = .image
|
||||
var total = 0
|
||||
var page = 1
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var isMutating = false
|
||||
var errorMessage: String?
|
||||
@Published var folder: AlbumFolderItem?
|
||||
@Published var files: [AlbumFileItem] = []
|
||||
@Published var selectedTab: AlbumFileTab = .image
|
||||
@Published var total = 0
|
||||
@Published var page = 1
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var isMutating = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
@ -863,16 +857,15 @@ final class AlbumDetailViewModel {
|
||||
|
||||
/// 相册预览上传 ViewModel,负责选择相册、本地文件上传和相册入库。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AlbumTrailerViewModel {
|
||||
var folders: [AlbumFolderItem] = []
|
||||
var selectedFolderId: Int?
|
||||
var localFiles: [AlbumLocalUploadFile] = []
|
||||
var uploadProgress = 0
|
||||
var isLoadingFolders = false
|
||||
var isSubmitting = false
|
||||
var errorMessage: String?
|
||||
var didUploadSuccessfully = false
|
||||
final class AlbumTrailerViewModel: ObservableObject {
|
||||
@Published var folders: [AlbumFolderItem] = []
|
||||
@Published var selectedFolderId: Int?
|
||||
@Published var localFiles: [AlbumLocalUploadFile] = []
|
||||
@Published var uploadProgress = 0
|
||||
@Published var isLoadingFolders = false
|
||||
@Published var isSubmitting = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var didUploadSuccessfully = false
|
||||
|
||||
/// 加载可上传的相册列表。
|
||||
func loadFolders(api: any AssetsServing, scenicId: Int?) async {
|
||||
|
||||
@ -12,12 +12,12 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// 相册管理页面,展示相册列表、筛选和新建相册入口。
|
||||
struct AlbumListView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = AlbumListViewModel()
|
||||
@StateObject private var viewModel = AlbumListViewModel()
|
||||
@State private var showCreateSheet = false
|
||||
@State private var newAlbumName = ""
|
||||
@State private var newAlbumRemark = ""
|
||||
@ -158,12 +158,12 @@ struct AlbumListView: View {
|
||||
|
||||
/// 相册详情页面,展示图片/视频列表并支持封面、删除和编辑。
|
||||
struct AlbumDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel: AlbumDetailViewModel
|
||||
@StateObject private var viewModel: AlbumDetailViewModel
|
||||
@State private var actionFile: AlbumFileItem?
|
||||
@State private var previewFile: AlbumFileItem?
|
||||
@State private var showRenameSheet = false
|
||||
@ -174,7 +174,7 @@ struct AlbumDetailView: View {
|
||||
|
||||
/// 初始化相册详情页面。
|
||||
init(folderId: Int, summary: AlbumFolderItem? = nil) {
|
||||
_viewModel = State(initialValue: AlbumDetailViewModel(folderId: folderId, summary: summary))
|
||||
_viewModel = StateObject(wrappedValue: AlbumDetailViewModel(folderId: folderId, summary: summary))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -282,7 +282,7 @@ struct AlbumDetailView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedTab) { _, tab in
|
||||
.onChange(of: viewModel.selectedTab) { tab in
|
||||
Task { await viewModel.selectTab(tab, api: assetsAPI, scenicId: accountContext.currentScenic?.id) }
|
||||
}
|
||||
}
|
||||
@ -378,14 +378,14 @@ struct AlbumTrailerEntryView: View {
|
||||
|
||||
/// 相册预览上传页面,负责选择相册、本地文件和提交上传。
|
||||
struct AlbumTrailerUploadView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = AlbumTrailerViewModel()
|
||||
@StateObject private var viewModel = AlbumTrailerViewModel()
|
||||
@State private var selectedItems: [PhotosPickerItem] = []
|
||||
|
||||
let initialFolderId: Int?
|
||||
@ -412,7 +412,7 @@ struct AlbumTrailerUploadView: View {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedItems) { _, items in
|
||||
.onChange(of: selectedItems) { items in
|
||||
Task { await loadPickedFiles(items) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,14 +14,14 @@ import UIKit
|
||||
|
||||
/// 相册云盘页面,展示文件夹浏览、筛选、上传、预览和文件操作。
|
||||
struct CloudStorageView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = CloudStorageViewModel()
|
||||
@StateObject private var viewModel = CloudStorageViewModel()
|
||||
@State private var transferStore = CloudTransferStore.shared
|
||||
@State private var selectedItems: [PhotosPickerItem] = []
|
||||
@State private var actionItem: CloudDriveFile?
|
||||
@ -70,7 +70,7 @@ struct CloudStorageView: View {
|
||||
guard viewModel.files.isEmpty else { return }
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
.onChange(of: selectedItems) { _, items in
|
||||
.onChange(of: selectedItems) { items in
|
||||
Task { await upload(items) }
|
||||
}
|
||||
.confirmationDialog("文件操作", isPresented: actionDialogBinding, presenting: actionItem) { item in
|
||||
@ -176,7 +176,7 @@ struct CloudStorageView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedFilter) { _, _ in
|
||||
.onChange(of: viewModel.selectedFilter) { _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
@ -186,7 +186,7 @@ struct CloudStorageView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedSort) { _, _ in
|
||||
.onChange(of: viewModel.selectedSort) { _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,18 +12,18 @@ import SwiftUI
|
||||
struct MediaLibraryView: View {
|
||||
let kind: MediaLibraryKind
|
||||
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel: MediaLibraryViewModel
|
||||
@StateObject private var viewModel: MediaLibraryViewModel
|
||||
@State private var selectedItem: MediaLibraryItem?
|
||||
|
||||
/// 初始化媒体库页面。
|
||||
init(kind: MediaLibraryKind = .material) {
|
||||
self.kind = kind
|
||||
_viewModel = State(initialValue: MediaLibraryViewModel(kind: kind))
|
||||
_viewModel = StateObject(wrappedValue: MediaLibraryViewModel(kind: kind))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -54,7 +54,7 @@ struct MediaLibraryView: View {
|
||||
guard viewModel.items.isEmpty else { return }
|
||||
await reload(showLoading: true)
|
||||
}
|
||||
.navigationDestination(item: $selectedItem) { item in
|
||||
.appNavigationDestination(item: $selectedItem) { item in
|
||||
MediaLibraryDetailView(item: item, listViewModel: viewModel)
|
||||
}
|
||||
}
|
||||
@ -82,7 +82,7 @@ struct MediaLibraryView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedAuditFilter) { _, _ in
|
||||
.onChange(of: viewModel.selectedAuditFilter) { _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
}
|
||||
@ -235,8 +235,8 @@ struct MediaLibraryDetailView: View {
|
||||
let item: MediaLibraryItem
|
||||
let listViewModel: MediaLibraryViewModel
|
||||
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@ -415,15 +415,15 @@ struct MediaLibraryUploadView: View {
|
||||
let kind: MediaLibraryKind
|
||||
let editingDetail: MediaLibraryDetail?
|
||||
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(AssetsAPI.self) private var assetsAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.assetsAPI) private var assetsAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel: MediaLibraryEditorViewModel
|
||||
@StateObject private var viewModel: MediaLibraryEditorViewModel
|
||||
@State private var coverSelection: PhotosPickerItem?
|
||||
@State private var mediaSelection: [PhotosPickerItem] = []
|
||||
|
||||
@ -431,7 +431,7 @@ struct MediaLibraryUploadView: View {
|
||||
init(kind: MediaLibraryKind = .material, editingDetail: MediaLibraryDetail? = nil) {
|
||||
self.kind = kind
|
||||
self.editingDetail = editingDetail
|
||||
_viewModel = State(initialValue: MediaLibraryEditorViewModel(detail: editingDetail))
|
||||
_viewModel = StateObject(wrappedValue: MediaLibraryEditorViewModel(detail: editingDetail))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -457,10 +457,10 @@ struct MediaLibraryUploadView: View {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
.onChange(of: coverSelection) { _, item in
|
||||
.onChange(of: coverSelection) { item in
|
||||
Task { await loadCover(item) }
|
||||
}
|
||||
.onChange(of: mediaSelection) { _, items in
|
||||
.onChange(of: mediaSelection) { items in
|
||||
Task { await loadMedia(items) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录认证 API,封装 v9 登录和账号选择相关接口。
|
||||
final class AuthAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化登录 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 登录输入焦点实体,标识当前需要聚焦的输入框。
|
||||
enum LoginField: Hashable {
|
||||
@ -68,17 +68,16 @@ enum LoginFlowError: LocalizedError {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。
|
||||
final class LoginViewModel {
|
||||
var username = "18651857230"
|
||||
var password = "zhifly666"
|
||||
var privacyChecked = false
|
||||
var isLoading = false
|
||||
var isSelectingAccount = false
|
||||
var showsPassword = false
|
||||
var showsAgreementSheet = false
|
||||
var pendingAccountSelection: AccountSelectionPayload?
|
||||
final class LoginViewModel: ObservableObject {
|
||||
@Published var username = "18651857230"
|
||||
@Published var password = "zhifly666"
|
||||
@Published var privacyChecked = false
|
||||
@Published var isLoading = false
|
||||
@Published var isSelectingAccount = false
|
||||
@Published var showsPassword = false
|
||||
@Published var showsAgreementSheet = false
|
||||
@Published var pendingAccountSelection: AccountSelectionPayload?
|
||||
|
||||
var canSubmit: Bool {
|
||||
isValidPhone && !trimmedPassword.isEmpty
|
||||
|
||||
@ -44,7 +44,7 @@ struct AccountSelectionView: View {
|
||||
@ViewBuilder
|
||||
private var accountList: some View {
|
||||
if payload.accounts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"暂无可用账号",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark",
|
||||
description: Text("当前账号没有可用的景区账号或门店账号,请联系管理员。")
|
||||
|
||||
@ -9,17 +9,17 @@ import SwiftUI
|
||||
|
||||
/// 登录页视图,负责展示登录表单、协议确认和多账号选择入口。
|
||||
struct LoginView: View {
|
||||
@Environment(AppSession.self) private var appSession
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(AuthAPI.self) private var authAPI
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@EnvironmentObject private var appSession: AppSession
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.authAPI) private var authAPI
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@State private var viewModel = LoginViewModel()
|
||||
@StateObject private var viewModel = LoginViewModel()
|
||||
@FocusState private var focusedField: LoginField?
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
@ -76,11 +76,11 @@ struct LoginView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.username) { _, _ in
|
||||
.onChange(of: viewModel.username) { _ in
|
||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||
dismissLoginToastIfNeeded()
|
||||
}
|
||||
.onChange(of: viewModel.password) { _, _ in
|
||||
.onChange(of: viewModel.password) { _ in
|
||||
dismissLoginToastIfNeeded()
|
||||
}
|
||||
.task {
|
||||
@ -462,12 +462,12 @@ private struct LoginAgreementConsentSheet: View {
|
||||
|
||||
#Preview {
|
||||
LoginView()
|
||||
.environment(AppSession())
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(ToastCenter())
|
||||
.environment(AuthAPI(client: APIClient()))
|
||||
.environment(ProfileAPI(client: APIClient()))
|
||||
.environment(AccountContextAPI(client: APIClient()))
|
||||
.environment(AuthSessionCoordinator())
|
||||
.environmentObject(AppSession())
|
||||
.environmentObject(AccountContext())
|
||||
.environmentObject(PermissionContext())
|
||||
.environmentObject(ToastCenter())
|
||||
.environment(\.authAPI, AuthAPI(client: APIClient()))
|
||||
.environment(\.profileAPI, ProfileAPI(client: APIClient()))
|
||||
.environment(\.accountContextAPI, AccountContextAPI(client: APIClient()))
|
||||
.environment(\.authSessionCoordinator, AuthSessionCoordinator())
|
||||
}
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 首页 ViewModel,根据当前角色权限构建首页菜单列表。
|
||||
final class HomeViewModel {
|
||||
private(set) var menuItems: [HomeMenuItem] = []
|
||||
final class HomeViewModel: ObservableObject {
|
||||
@Published private(set) var menuItems: [HomeMenuItem] = []
|
||||
|
||||
/// 菜单排序权重,与旧工程和 Android 端保持一致。
|
||||
private let preferredOrder: [String] = [
|
||||
|
||||
@ -9,12 +9,12 @@ import SwiftUI
|
||||
|
||||
/// 首页全部功能视图,展示常用应用和当前角色拥有的更多功能。
|
||||
struct HomeMoreFunctionsView: View {
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = HomeViewModel()
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
@State private var commonUris: [String] = []
|
||||
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
@ -49,10 +49,10 @@ struct HomeMoreFunctionsView: View {
|
||||
.task {
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _, _ in
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _, _ in
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _ in
|
||||
rebuildMenus()
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,13 +10,13 @@ import SwiftUI
|
||||
|
||||
/// 首页主视图,展示景区、工作状态、快捷操作和权限菜单入口。
|
||||
struct HomeView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = HomeViewModel()
|
||||
@StateObject private var viewModel = HomeViewModel()
|
||||
@State private var commonUris: [String] = []
|
||||
@State private var isOnline = false
|
||||
@State private var reminderMinutes = 0
|
||||
@ -113,10 +113,10 @@ struct HomeView: View {
|
||||
guard isOnline, secondsUntilReport > 0 else { return }
|
||||
secondsUntilReport -= 1
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _, _ in
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
rebuildMenusFromCurrentContext()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _, _ in
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _ in
|
||||
rebuildMenusFromCurrentContext()
|
||||
}
|
||||
.alert("切换在线状态", isPresented: $showOnlineDialog) {
|
||||
@ -423,9 +423,9 @@ struct HomeView: View {
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
HomeView()
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(AppRouter())
|
||||
.environment(RouterPath())
|
||||
.environmentObject(AccountContext())
|
||||
.environmentObject(PermissionContext())
|
||||
.environmentObject(AppRouter())
|
||||
.environmentObject(RouterPath())
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 邀请服务协议,抽象邀请信息和邀请记录接口。
|
||||
@MainActor
|
||||
@ -20,9 +20,8 @@ protocol InviteServing {
|
||||
|
||||
/// 邀请 API,封装摄影师邀请相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class InviteAPI: InviteServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化邀请 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -7,22 +7,21 @@
|
||||
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
/// 邀请页 ViewModel,负责加载邀请信息、生成二维码和复制内容。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PhotographerInviteViewModel {
|
||||
private(set) var loading = false
|
||||
private(set) var inviteCode = ""
|
||||
private(set) var inviteUrl = ""
|
||||
private(set) var rules: [String] = []
|
||||
private(set) var qrImage: UIImage?
|
||||
var errorMessage: String?
|
||||
final class PhotographerInviteViewModel: ObservableObject {
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var inviteCode = ""
|
||||
@Published private(set) var inviteUrl = ""
|
||||
@Published private(set) var rules: [String] = []
|
||||
@Published private(set) var qrImage: UIImage?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let qrContext = CIContext()
|
||||
@ObservationIgnored private let qrFilter = CIFilter.qrCodeGenerator()
|
||||
private let qrContext = CIContext()
|
||||
private let qrFilter = CIFilter.qrCodeGenerator()
|
||||
|
||||
/// 重新加载邀请信息。
|
||||
func reload(api: any InviteServing) async {
|
||||
@ -72,21 +71,20 @@ final class PhotographerInviteViewModel {
|
||||
|
||||
/// 邀请记录 ViewModel,负责邀请用户、奖励明细和钱包汇总分页。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class InviteRecordViewModel {
|
||||
var tab: InviteRecordTab = .invite
|
||||
private(set) var totalRewardText = "¥ 0.00"
|
||||
private(set) var withdrawableText = "¥ 0.00"
|
||||
private(set) var displayRows: [InviteDisplayRow] = []
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var hasMore = false
|
||||
var errorMessage: String?
|
||||
final class InviteRecordViewModel: ObservableObject {
|
||||
@Published var tab: InviteRecordTab = .invite
|
||||
@Published private(set) var totalRewardText = "¥ 0.00"
|
||||
@Published private(set) var withdrawableText = "¥ 0.00"
|
||||
@Published private(set) var displayRows: [InviteDisplayRow] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var hasMore = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var inviteRows: [InviteDisplayRow] = []
|
||||
private var rewardRows: [InviteDisplayRow] = []
|
||||
private var invitePage = 1
|
||||
private var rewardPage = 1
|
||||
@Published private var inviteRows: [InviteDisplayRow] = []
|
||||
@Published private var rewardRows: [InviteDisplayRow] = []
|
||||
@Published private var invitePage = 1
|
||||
@Published private var rewardPage = 1
|
||||
private let invitePageSize = 20
|
||||
private let rewardPageSize = 10
|
||||
|
||||
|
||||
@ -9,10 +9,10 @@ import SwiftUI
|
||||
|
||||
/// 摄影师邀请页面,展示邀请二维码、邀请码、规则和邀请记录入口。
|
||||
struct PhotographerInviteView: View {
|
||||
@Environment(InviteAPI.self) private var inviteAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.inviteAPI) private var inviteAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = PhotographerInviteViewModel()
|
||||
@StateObject private var viewModel = PhotographerInviteViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -140,10 +140,10 @@ struct PhotographerInviteView: View {
|
||||
|
||||
/// 邀请记录页面,展示邀请用户和奖励记录。
|
||||
struct InviteRecordView: View {
|
||||
@Environment(InviteAPI.self) private var inviteAPI
|
||||
@Environment(WalletAPI.self) private var walletAPI
|
||||
@Environment(\.inviteAPI) private var inviteAPI
|
||||
@Environment(\.walletAPI) private var walletAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = InviteRecordViewModel()
|
||||
@StateObject private var viewModel = InviteRecordViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@ -155,7 +155,7 @@ struct InviteRecordView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.onChange(of: viewModel.tab) { _, newValue in
|
||||
.onChange(of: viewModel.tab) { newValue in
|
||||
Task { await viewModel.selectTab(newValue, inviteAPI: inviteAPI, walletAPI: walletAPI) }
|
||||
}
|
||||
recordList
|
||||
@ -205,7 +205,7 @@ struct InviteRecordView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if viewModel.displayRows.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView("暂无记录", systemImage: "tray")
|
||||
AppContentUnavailableView("暂无记录", systemImage: "tray")
|
||||
.frame(minHeight: 280)
|
||||
}
|
||||
ForEach(viewModel.displayRows) { row in
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 直播服务协议,定义直播管理和直播相册接口能力。
|
||||
@MainActor
|
||||
@ -26,10 +26,9 @@ protocol LiveServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播 API,封装手动直播和直播相册网络请求。
|
||||
final class LiveAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化直播 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。
|
||||
enum LivePlaybackURLResolver {
|
||||
@ -44,13 +44,12 @@ enum LivePlaybackState: Equatable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播播放器 ViewModel,管理系统播放器的 URL、播放和释放状态。
|
||||
final class LivePlaybackViewModel {
|
||||
var state: LivePlaybackState = .empty
|
||||
var errorMessage: String?
|
||||
final class LivePlaybackViewModel: ObservableObject {
|
||||
@Published var state: LivePlaybackState = .empty
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private(set) var player: AVPlayer?
|
||||
@Published private(set) var player: AVPlayer?
|
||||
|
||||
var playableURL: URL? {
|
||||
switch state {
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 直播推流权限状态。
|
||||
enum LivePushPermissionState: Equatable {
|
||||
@ -158,23 +158,22 @@ final class SystemLiveNetworkMonitor: LiveNetworkMonitoring {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 推流准备 ViewModel,检查权限、网络和默认 SDK 可用性。
|
||||
final class LivePushReadinessViewModel {
|
||||
var cameraPermission: LivePushPermissionState = .unknown
|
||||
var microphonePermission: LivePushPermissionState = .unknown
|
||||
var networkState: LivePushNetworkState = .unknown
|
||||
var prepared = false
|
||||
var running = false
|
||||
var errorMessage: String?
|
||||
final class LivePushReadinessViewModel: ObservableObject {
|
||||
@Published var cameraPermission: LivePushPermissionState = .unknown
|
||||
@Published var microphonePermission: LivePushPermissionState = .unknown
|
||||
@Published var networkState: LivePushNetworkState = .unknown
|
||||
@Published var prepared = false
|
||||
@Published var running = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
let adapterName: String
|
||||
let sdkStatusText: String
|
||||
|
||||
@ObservationIgnored private let permissionProvider: any LivePermissionProviding
|
||||
@ObservationIgnored private let networkMonitor: any LiveNetworkMonitoring
|
||||
@ObservationIgnored private let adapter: any LivePushAdapter
|
||||
@ObservationIgnored private var pushURL: URL?
|
||||
private let permissionProvider: any LivePermissionProviding
|
||||
private let networkMonitor: any LiveNetworkMonitoring
|
||||
private let adapter: any LivePushAdapter
|
||||
@Published private var pushURL: URL?
|
||||
|
||||
init(
|
||||
permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(),
|
||||
|
||||
@ -6,22 +6,21 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播管理 ViewModel,负责直播列表、详情、创建和控制动作。
|
||||
final class LiveManagementViewModel {
|
||||
var items: [LiveEntity] = []
|
||||
var detail: LiveEntity?
|
||||
var loading = false
|
||||
var loadingMore = false
|
||||
var hasMore = false
|
||||
var page = 1
|
||||
var errorMessage: String?
|
||||
final class LiveManagementViewModel: ObservableObject {
|
||||
@Published var items: [LiveEntity] = []
|
||||
@Published var detail: LiveEntity?
|
||||
@Published var loading = false
|
||||
@Published var loadingMore = false
|
||||
@Published var hasMore = false
|
||||
@Published var page = 1
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let pageSize = 10
|
||||
@ObservationIgnored private var total = 0
|
||||
private let pageSize = 10
|
||||
@Published private var total = 0
|
||||
|
||||
/// 进行中的直播数量。
|
||||
var liveRunningCount: Int {
|
||||
@ -136,13 +135,12 @@ final class LiveManagementViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播详情 ViewModel,负责直播详情页动作后的详情刷新。
|
||||
final class LiveDetailViewModel {
|
||||
var detail: LiveEntity
|
||||
var loading = false
|
||||
var actionInFlight = false
|
||||
var errorMessage: String?
|
||||
final class LiveDetailViewModel: ObservableObject {
|
||||
@Published var detail: LiveEntity
|
||||
@Published var loading = false
|
||||
@Published var actionInFlight = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
init(detail: LiveEntity) {
|
||||
self.detail = detail
|
||||
@ -192,20 +190,19 @@ final class LiveDetailViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播相册 ViewModel,负责相册列表、筛选、新建和删除。
|
||||
final class LiveAlbumViewModel {
|
||||
var folders: [LiveAlbumFolderItem] = []
|
||||
var startDate: Date?
|
||||
var endDate: Date?
|
||||
var loading = false
|
||||
var loadingMore = false
|
||||
var hasMore = false
|
||||
var page = 1
|
||||
var errorMessage: String?
|
||||
final class LiveAlbumViewModel: ObservableObject {
|
||||
@Published var folders: [LiveAlbumFolderItem] = []
|
||||
@Published var startDate: Date?
|
||||
@Published var endDate: Date?
|
||||
@Published var loading = false
|
||||
@Published var loadingMore = false
|
||||
@Published var hasMore = false
|
||||
@Published var page = 1
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let pageSize = 10
|
||||
@ObservationIgnored private var total = 0
|
||||
private let pageSize = 10
|
||||
@Published private var total = 0
|
||||
|
||||
/// 设置开始时间并校验时间顺序。
|
||||
func setStartDate(_ date: Date) throws {
|
||||
@ -301,14 +298,13 @@ final class LiveAlbumViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 新建直播相册 ViewModel,负责本地素材上传和创建相册。
|
||||
final class LiveAlbumCreateViewModel {
|
||||
var name = ""
|
||||
var localFiles: [LiveAlbumLocalUploadFile] = []
|
||||
var submitting = false
|
||||
var uploadProgress = 0
|
||||
var errorMessage: String?
|
||||
final class LiveAlbumCreateViewModel: ObservableObject {
|
||||
@Published var name = ""
|
||||
@Published var localFiles: [LiveAlbumLocalUploadFile] = []
|
||||
@Published var submitting = false
|
||||
@Published var uploadProgress = 0
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 添加本地待上传素材。
|
||||
func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) {
|
||||
@ -368,15 +364,14 @@ final class LiveAlbumCreateViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 直播相册预览 ViewModel,负责加载相册详情和删除当前素材。
|
||||
final class LiveAlbumPreviewViewModel {
|
||||
final class LiveAlbumPreviewViewModel: ObservableObject {
|
||||
let folderId: Int
|
||||
var folder: LiveAlbumFolderItem?
|
||||
var files: [LiveAlbumFileItem] = []
|
||||
var currentIndex: Int
|
||||
var loading = false
|
||||
var errorMessage: String?
|
||||
@Published var folder: LiveAlbumFolderItem?
|
||||
@Published var files: [LiveAlbumFileItem] = []
|
||||
@Published var currentIndex: Int
|
||||
@Published var loading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) {
|
||||
self.folderId = folderId
|
||||
|
||||
@ -12,12 +12,12 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// 直播相册首页,展示相册列表、筛选和上传入口。
|
||||
struct LiveAlbumView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LiveAlbumViewModel()
|
||||
@StateObject private var viewModel = LiveAlbumViewModel()
|
||||
@State private var showStartPicker = false
|
||||
@State private var showEndPicker = false
|
||||
@State private var showCreatePage = false
|
||||
@ -29,7 +29,7 @@ struct LiveAlbumView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.folders.isEmpty {
|
||||
ContentUnavailableView("暂无素材", systemImage: "photo.stack")
|
||||
AppContentUnavailableView("暂无素材", systemImage: "photo.stack")
|
||||
.frame(maxWidth: .infinity, minHeight: 300)
|
||||
} else {
|
||||
ForEach(viewModel.folders) { folder in
|
||||
@ -286,13 +286,13 @@ private struct LiveDatePickerSheet: View {
|
||||
|
||||
/// 新建直播相册页面,支持选择本地图片/视频并上传。
|
||||
struct LiveAlbumCreateView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = LiveAlbumCreateViewModel()
|
||||
@StateObject private var viewModel = LiveAlbumCreateViewModel()
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
let onCreated: () -> Void
|
||||
|
||||
@ -330,7 +330,7 @@ struct LiveAlbumCreateView: View {
|
||||
.background(Color(hex: 0xF5F7FA))
|
||||
.navigationTitle("上传素材")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onChange(of: pickerItems) { _, newItems in
|
||||
.onChange(of: pickerItems) { newItems in
|
||||
Task { await importPickerItems(newItems) }
|
||||
}
|
||||
}
|
||||
@ -338,7 +338,7 @@ struct LiveAlbumCreateView: View {
|
||||
@ViewBuilder
|
||||
private var localFileGrid: some View {
|
||||
if viewModel.localFiles.isEmpty {
|
||||
ContentUnavailableView("未选择素材", systemImage: "photo")
|
||||
AppContentUnavailableView("未选择素材", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
} else {
|
||||
@ -392,21 +392,21 @@ struct LiveAlbumCreateView: View {
|
||||
|
||||
/// 直播相册预览页面。
|
||||
struct LiveAlbumPreviewView: View {
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel: LiveAlbumPreviewViewModel
|
||||
@StateObject private var viewModel: LiveAlbumPreviewViewModel
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
init(folderId: Int, startIndex: Int, summary: LiveAlbumFolderItem?) {
|
||||
_viewModel = State(initialValue: LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex, summary: summary))
|
||||
_viewModel = StateObject(wrappedValue: LiveAlbumPreviewViewModel(folderId: folderId, startIndex: startIndex, summary: summary))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.files.isEmpty {
|
||||
ContentUnavailableView("没有可预览的素材", systemImage: "photo")
|
||||
AppContentUnavailableView("没有可预览的素材", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
} else {
|
||||
@ -459,12 +459,12 @@ struct LiveAlbumPreviewView: View {
|
||||
|
||||
private struct LiveAlbumPreviewPage: View {
|
||||
let file: LiveAlbumFileItem
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var playbackViewModel: LivePlaybackViewModel
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var playbackViewModel: LivePlaybackViewModel
|
||||
|
||||
init(file: LiveAlbumFileItem) {
|
||||
self.file = file
|
||||
_playbackViewModel = State(initialValue: LivePlaybackViewModel(urlString: file.url))
|
||||
_playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(urlString: file.url))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@ -10,12 +10,12 @@ import SwiftUI
|
||||
|
||||
/// 直播管理首页,展示直播列表和控制入口。
|
||||
struct LiveManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LiveManagementViewModel()
|
||||
@StateObject private var viewModel = LiveManagementViewModel()
|
||||
@State private var showAddPage = false
|
||||
|
||||
var body: some View {
|
||||
@ -24,7 +24,7 @@ struct LiveManagementView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.items.isEmpty {
|
||||
ContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right")
|
||||
AppContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right")
|
||||
.frame(maxWidth: .infinity, minHeight: 280)
|
||||
} else {
|
||||
ForEach(viewModel.items) { item in
|
||||
@ -231,9 +231,9 @@ private struct LiveSmallAction: View {
|
||||
|
||||
/// 添加直播页面。
|
||||
struct LiveAddView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var title = ""
|
||||
@ -351,18 +351,18 @@ struct LiveAddView: View {
|
||||
|
||||
/// 直播详情页面,展示推流信息和控制动作。
|
||||
struct LiveDetailView: View {
|
||||
@Environment(LiveAPI.self) private var liveAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.liveAPI) private var liveAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel: LiveDetailViewModel
|
||||
@State private var playbackViewModel: LivePlaybackViewModel
|
||||
@State private var pushReadinessViewModel = LivePushReadinessViewModel()
|
||||
@StateObject private var viewModel: LiveDetailViewModel
|
||||
@StateObject private var playbackViewModel: LivePlaybackViewModel
|
||||
@StateObject private var pushReadinessViewModel = LivePushReadinessViewModel()
|
||||
@State private var copied = false
|
||||
let onChanged: () -> Void
|
||||
|
||||
init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) {
|
||||
_viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail))
|
||||
_playbackViewModel = State(initialValue: LivePlaybackViewModel(live: initialDetail))
|
||||
_viewModel = StateObject(wrappedValue: LiveDetailViewModel(detail: initialDetail))
|
||||
_playbackViewModel = StateObject(wrappedValue: LivePlaybackViewModel(live: initialDetail))
|
||||
self.onChanged = onChanged
|
||||
}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 位置上报服务协议,抽象上报和历史接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
@ -20,9 +20,8 @@ protocol LocationReportServing {
|
||||
|
||||
/// 位置上报 API,负责封装旧工程定位上报相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LocationReportAPI: LocationReportServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化位置上报 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,22 +6,21 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 位置上报 ViewModel,负责页面内在线状态、坐标、倒计时和提交动作。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LocationReportViewModel {
|
||||
var isOnline = false
|
||||
var reminderMinutes = 30
|
||||
var secondsUntilReport = 0
|
||||
var currentCoordinate: LocationCoordinate?
|
||||
var markedCoordinate: LocationCoordinate?
|
||||
var currentAddress = ""
|
||||
var markedAddress = ""
|
||||
var lastReportText = ""
|
||||
var errorMessage: String?
|
||||
var isSubmitting = false
|
||||
final class LocationReportViewModel: ObservableObject {
|
||||
@Published var isOnline = false
|
||||
@Published var reminderMinutes = 30
|
||||
@Published var secondsUntilReport = 0
|
||||
@Published var currentCoordinate: LocationCoordinate?
|
||||
@Published var markedCoordinate: LocationCoordinate?
|
||||
@Published var currentAddress = ""
|
||||
@Published var markedAddress = ""
|
||||
@Published var lastReportText = ""
|
||||
@Published var errorMessage: String?
|
||||
@Published var isSubmitting = false
|
||||
|
||||
/// 倒计时展示文案。
|
||||
var countdownText: String {
|
||||
@ -130,18 +129,17 @@ final class LocationReportViewModel {
|
||||
|
||||
/// 位置上报历史 ViewModel,负责筛选、日期和分页加载。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LocationReportHistoryViewModel {
|
||||
var selectedType: LocationReportType = .all
|
||||
var startDate: Date?
|
||||
var endDate: Date?
|
||||
var items: [LocationReportHistoryItem] = []
|
||||
var errorMessage: String?
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var total = 0
|
||||
final class LocationReportHistoryViewModel: ObservableObject {
|
||||
@Published var selectedType: LocationReportType = .all
|
||||
@Published var startDate: Date?
|
||||
@Published var endDate: Date?
|
||||
@Published var items: [LocationReportHistoryItem] = []
|
||||
@Published var errorMessage: String?
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var total = 0
|
||||
|
||||
private var page = 1
|
||||
@Published private var page = 1
|
||||
private let pageSize = 20
|
||||
|
||||
/// 是否还有下一页历史记录。
|
||||
|
||||
@ -10,14 +10,14 @@ import SwiftUI
|
||||
|
||||
/// 位置上报页面,支持当前位置上报、标记点上报、在线状态和提醒设置。
|
||||
struct LocationReportView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(LocationReportAPI.self) private var locationReportAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.locationReportAPI) private var locationReportAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LocationReportViewModel()
|
||||
@StateObject private var viewModel = LocationReportViewModel()
|
||||
@State private var locationProvider = ForegroundLocationProvider()
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
@ -242,12 +242,12 @@ struct LocationReportView: View {
|
||||
|
||||
/// 位置上报历史页面,展示历史记录筛选和分页。
|
||||
struct LocationReportHistoryView: View {
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(LocationReportAPI.self) private var locationReportAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.locationReportAPI) private var locationReportAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = LocationReportHistoryViewModel()
|
||||
@StateObject private var viewModel = LocationReportHistoryViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -277,7 +277,7 @@ struct LocationReportHistoryView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedType) { _, _ in
|
||||
.onChange(of: viewModel.selectedType) { _ in
|
||||
Task { await reload(showLoading: true) }
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 主 Tab 角标 ViewModel,负责获取订单 Tab 待核销数量。
|
||||
final class MainTabBadgeViewModel {
|
||||
private(set) var pendingWriteOffCount: Int?
|
||||
final class MainTabBadgeViewModel: ObservableObject {
|
||||
@Published private(set) var pendingWriteOffCount: Int?
|
||||
|
||||
/// 刷新待核销订单数量,缺少景区或接口失败时清空角标。
|
||||
func refreshPendingWriteOffCount(api: OrderServing, scenicId: Int?, storeId: Int?) async {
|
||||
|
||||
@ -32,11 +32,9 @@ struct MainTabsView: View {
|
||||
|
||||
/// 系统 TabView 外壳,保留原生 TabBar 行为以便后续切换回系统实现。
|
||||
private struct SystemMainTabsView: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appRouter = appRouter
|
||||
|
||||
TabView(selection: $appRouter.selectedTab) {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
TabNavigationStackHost(tab: tab)
|
||||
@ -49,12 +47,12 @@ private struct SystemMainTabsView: View {
|
||||
|
||||
/// 自定义 TabBar 外壳,复用每个 Tab 独立 NavigationStack 并保留已访问页面状态。
|
||||
private struct CustomMainTabsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
@State private var badgeViewModel = MainTabBadgeViewModel()
|
||||
@StateObject private var badgeViewModel = MainTabBadgeViewModel()
|
||||
@State private var showScanner = false
|
||||
@State private var loadedTabs: Set<AppTab> = [.home]
|
||||
|
||||
@ -66,8 +64,6 @@ private struct CustomMainTabsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appRouter = appRouter
|
||||
|
||||
ZStack {
|
||||
ForEach(AppTab.allCases) { tab in
|
||||
PreservedTabPage(
|
||||
@ -102,15 +98,15 @@ private struct CustomMainTabsView: View {
|
||||
loadedTabs.insert(appRouter.selectedTab)
|
||||
await refreshOrderBadge()
|
||||
}
|
||||
.onChange(of: appRouter.selectedTab) { _, selectedTab in
|
||||
.onChange(of: appRouter.selectedTab) { selectedTab in
|
||||
loadedTabs.insert(selectedTab)
|
||||
guard selectedTab == .orders else { return }
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||||
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
.onChange(of: accountContext.currentStore?.id) { _, _ in
|
||||
.onChange(of: accountContext.currentStore?.id) { _ in
|
||||
Task { await refreshOrderBadge() }
|
||||
}
|
||||
}
|
||||
@ -127,7 +123,7 @@ private struct CustomMainTabsView: View {
|
||||
|
||||
/// 自定义 TabBar 模式下的单个 Tab 导航栈,TabBar 只属于根页面内容。
|
||||
private struct CustomTabNavigationStackHost: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
|
||||
let tab: AppTab
|
||||
@Binding var selectedTab: AppTab
|
||||
@ -153,13 +149,13 @@ private struct CustomTabNavigationStackHost: View {
|
||||
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
|
||||
}
|
||||
}
|
||||
.environment(appRouter.router(for: tab))
|
||||
.environmentObject(appRouter.router(for: tab))
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个 Tab 对应的导航栈,供系统和自定义 TabBar 外壳共同复用。
|
||||
private struct TabNavigationStackHost: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
|
||||
let tab: AppTab
|
||||
|
||||
@ -172,7 +168,7 @@ private struct TabNavigationStackHost: View {
|
||||
.toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar)
|
||||
}
|
||||
}
|
||||
.environment(appRouter.router(for: tab))
|
||||
.environmentObject(appRouter.router(for: tab))
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,8 +193,8 @@ private struct PreservedTabPage<Content: View>: View {
|
||||
|
||||
#Preview {
|
||||
MainTabsView()
|
||||
.environment(AppRouter())
|
||||
.environment(AccountContext())
|
||||
.environment(OrdersAPI(client: APIClient()))
|
||||
.environment(ToastCenter())
|
||||
.environmentObject(AppRouter())
|
||||
.environmentObject(AccountContext())
|
||||
.environment(\.ordersAPI, OrdersAPI(client: APIClient()))
|
||||
.environmentObject(ToastCenter())
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ struct ProfileRootView: View {
|
||||
|
||||
/// 通用 Tab 占位视图,用于未迁移模块的临时入口。
|
||||
private struct PlaceholderTabRootView: View {
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
|
||||
let title: String
|
||||
let systemImage: String
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 消息中心服务协议,定义消息列表、已读和删除能力。
|
||||
@MainActor
|
||||
@ -22,10 +22,9 @@ protocol MessageCenterServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 消息中心 API,封装消息相关网络请求。
|
||||
final class MessageCenterAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化消息中心 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,22 +6,21 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 消息中心 ViewModel,负责消息分页、筛选、已读和删除。
|
||||
final class MessageCenterViewModel {
|
||||
var messages: [MessageItem] = []
|
||||
var selectedFilter: MessageFilter = .all
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var message: String?
|
||||
final class MessageCenterViewModel: ObservableObject {
|
||||
@Published var messages: [MessageItem] = []
|
||||
@Published var selectedFilter: MessageFilter = .all
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var loadFailed = false
|
||||
@Published var loadFailureReason: String?
|
||||
@Published var message: String?
|
||||
|
||||
private(set) var hasMoreMessages = false
|
||||
private(set) var lastId = 0
|
||||
@Published private(set) var hasMoreMessages = false
|
||||
@Published private(set) var lastId = 0
|
||||
private let pageSize = 20
|
||||
|
||||
/// 未读消息数量。
|
||||
|
||||
@ -9,9 +9,9 @@ import SwiftUI
|
||||
|
||||
/// 消息中心页面,展示消息列表、筛选、已读和删除入口。
|
||||
struct MessageCenterView: View {
|
||||
@Environment(MessageCenterAPI.self) private var messageAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = MessageCenterViewModel()
|
||||
@Environment(\.messageCenterAPI) private var messageAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = MessageCenterViewModel()
|
||||
@State private var selectedMessage: MessageItem?
|
||||
@State private var processingMessageId: String?
|
||||
|
||||
@ -80,7 +80,7 @@ struct MessageCenterView: View {
|
||||
ProgressView("加载中...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.loadFailed {
|
||||
ContentUnavailableView {
|
||||
AppContentUnavailableView {
|
||||
Label("消息加载失败", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(viewModel.loadFailureReason ?? "请稍后重试")
|
||||
@ -92,7 +92,7 @@ struct MessageCenterView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.messages.isEmpty {
|
||||
ContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息"))
|
||||
AppContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。
|
||||
@MainActor
|
||||
@ -16,10 +16,9 @@ protocol OperatingAreaServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 运营区域 API,封装运营区域只读围栏接口。
|
||||
final class OperatingAreaAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化运营区域 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,18 +6,17 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 运营区域 ViewModel,负责角色分流、围栏加载、解析和错误状态管理。
|
||||
final class OperatingAreaViewModel {
|
||||
private(set) var mode: OperatingAreaEntryMode?
|
||||
private(set) var loading = false
|
||||
private(set) var items: [OperatingAreaItem] = []
|
||||
private(set) var fenceRings: [OperatingFenceRing] = []
|
||||
private(set) var blockReason: OperatingAreaBlockReason?
|
||||
private(set) var errorMessage: String?
|
||||
final class OperatingAreaViewModel: ObservableObject {
|
||||
@Published private(set) var mode: OperatingAreaEntryMode?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var items: [OperatingAreaItem] = []
|
||||
@Published private(set) var fenceRings: [OperatingFenceRing] = []
|
||||
@Published private(set) var blockReason: OperatingAreaBlockReason?
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
/// 按当前账号和角色刷新运营区域。
|
||||
func reload(
|
||||
|
||||
@ -14,13 +14,13 @@ import MAMapKit
|
||||
|
||||
/// 运营区域首页,按当前角色展示店铺或景区运营围栏。
|
||||
struct OperatingAreaView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(OperatingAreaAPI.self) private var operatingAreaAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@Environment(\.operatingAreaAPI) private var operatingAreaAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = OperatingAreaViewModel()
|
||||
@StateObject private var viewModel = OperatingAreaViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -87,7 +87,7 @@ struct OperatingAreaView: View {
|
||||
}
|
||||
|
||||
if let reason = viewModel.blockReason {
|
||||
ContentUnavailableView(reason.message, systemImage: "mappin.slash")
|
||||
AppContentUnavailableView(reason.message, systemImage: "mappin.slash")
|
||||
.frame(maxWidth: .infinity, minHeight: 260)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(alignment: .bottom) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
/// 订单服务协议,抽象订单列表、核销列表和核销操作以便测试替换。
|
||||
@ -59,10 +59,9 @@ protocol OrderServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 订单 API,封装订单管理、核销订单和订单核销接口。
|
||||
final class OrdersAPI: OrderServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化订单 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,16 +6,15 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 订单详情 ViewModel,负责加载门店订单详情并保留列表摘要兜底展示。
|
||||
final class OrderDetailViewModel {
|
||||
private(set) var detail: StoreOrderDetailResponse?
|
||||
private(set) var loading = false
|
||||
private(set) var errorMessage: String?
|
||||
private(set) var contextMessage: String?
|
||||
final class OrderDetailViewModel: ObservableObject {
|
||||
@Published private(set) var detail: StoreOrderDetailResponse?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var contextMessage: String?
|
||||
|
||||
/// 当前页面用于展示的订单信息,详情接口成功时优先使用详情数据。
|
||||
var display: StoreOrderDetailDisplay {
|
||||
|
||||
@ -6,20 +6,19 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。
|
||||
final class DepositOrderListViewModel {
|
||||
private(set) var orders: [DepositOrderListItem] = []
|
||||
private(set) var total = 0
|
||||
private(set) var page = 1
|
||||
private(set) var hasMore = false
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var operatingOrderNumber: String?
|
||||
var errorMessage: String?
|
||||
final class DepositOrderListViewModel: ObservableObject {
|
||||
@Published private(set) var orders: [DepositOrderListItem] = []
|
||||
@Published private(set) var total = 0
|
||||
@Published private(set) var page = 1
|
||||
@Published private(set) var hasMore = false
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var operatingOrderNumber: String?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
@ -134,12 +133,11 @@ final class DepositOrderListViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 押金订单详情 ViewModel,负责按门店和订单号加载详情。
|
||||
final class DepositOrderDetailViewModel {
|
||||
private(set) var detail: StoreOrderDetailResponse?
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
final class DepositOrderDetailViewModel: ObservableObject {
|
||||
@Published private(set) var detail: StoreOrderDetailResponse?
|
||||
@Published private(set) var loading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载押金订单详情,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String) async {
|
||||
@ -179,12 +177,11 @@ final class DepositOrderDetailViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。
|
||||
final class DepositOrderShootingInfoViewModel {
|
||||
private(set) var detail: StoreOrderShootingDetailResponse?
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
final class DepositOrderShootingInfoViewModel: ObservableObject {
|
||||
@Published private(set) var detail: StoreOrderShootingDetailResponse?
|
||||
@Published private(set) var loading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载押金订单拍摄信息,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async {
|
||||
@ -229,14 +226,13 @@ final class DepositOrderShootingInfoViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。
|
||||
final class OrderRefundViewModel {
|
||||
var mode: OrderRefundMode = .full
|
||||
var amount = ""
|
||||
var reason = ""
|
||||
private(set) var submitting = false
|
||||
var errorMessage: String?
|
||||
final class OrderRefundViewModel: ObservableObject {
|
||||
@Published var mode: OrderRefundMode = .full
|
||||
@Published var amount = ""
|
||||
@Published var reason = ""
|
||||
@Published private(set) var submitting = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 判断订单是否允许展示普通退款入口。
|
||||
func canRefund(_ item: OrderEntity) -> Bool {
|
||||
@ -344,14 +340,13 @@ final class OrderRefundViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。
|
||||
final class HistoricalShootingInfoViewModel {
|
||||
private(set) var projectName = ""
|
||||
private(set) var projectTypeName = ""
|
||||
private(set) var spots: [PhotogSpotItem] = []
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
final class HistoricalShootingInfoViewModel: ObservableObject {
|
||||
@Published private(set) var projectName = ""
|
||||
@Published private(set) var projectTypeName = ""
|
||||
@Published private(set) var spots: [PhotogSpotItem] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载历史拍摄信息,订单号为空时清空旧数据且不请求接口。
|
||||
func load(api: OrderServing, orderNumber: String) async {
|
||||
@ -390,20 +385,19 @@ final class HistoricalShootingInfoViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。
|
||||
final class MultiTravelTaskUploadViewModel {
|
||||
var orderNumber = ""
|
||||
var selectedSpotId: Int?
|
||||
private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = []
|
||||
var selectedCloudFiles: [TaskCloudSelectionItem] = []
|
||||
var selectedLocalFiles: [TaskLocalUploadItem] = []
|
||||
private(set) var isLoadingSpots = false
|
||||
private(set) var isSubmitting = false
|
||||
private(set) var didSubmitSuccessfully = false
|
||||
var errorMessage: String?
|
||||
final class MultiTravelTaskUploadViewModel: ObservableObject {
|
||||
@Published var orderNumber = ""
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = []
|
||||
@Published var selectedCloudFiles: [TaskCloudSelectionItem] = []
|
||||
@Published var selectedLocalFiles: [TaskLocalUploadItem] = []
|
||||
@Published private(set) var isLoadingSpots = false
|
||||
@Published private(set) var isSubmitting = false
|
||||
@Published private(set) var didSubmitSuccessfully = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var loadedSpotOrderNumber = ""
|
||||
@Published private var loadedSpotOrderNumber = ""
|
||||
|
||||
/// 当前选中打卡点展示名。
|
||||
var selectedSpotName: String {
|
||||
|
||||
@ -6,31 +6,30 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 订单页面 ViewModel,管理订单列表、核销列表、筛选条件和手动核销流程。
|
||||
final class OrdersViewModel {
|
||||
var selectedEntry: OrdersEntry = .storeOrders
|
||||
var selectedStatus = 0
|
||||
var searchPhone = ""
|
||||
var filterStartDate: Date?
|
||||
var filterEndDate: Date?
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var isVerifying = false
|
||||
private(set) var currentVerifyingOrderNumber: String?
|
||||
final class OrdersViewModel: ObservableObject {
|
||||
@Published var selectedEntry: OrdersEntry = .storeOrders
|
||||
@Published var selectedStatus = 0
|
||||
@Published var searchPhone = ""
|
||||
@Published var filterStartDate: Date?
|
||||
@Published var filterEndDate: Date?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var isVerifying = false
|
||||
@Published private(set) var currentVerifyingOrderNumber: String?
|
||||
|
||||
private(set) var storeOrders: [OrderEntity] = []
|
||||
private(set) var storeTotal = 0
|
||||
private(set) var storePage = 1
|
||||
private(set) var storeHasMore = false
|
||||
@Published private(set) var storeOrders: [OrderEntity] = []
|
||||
@Published private(set) var storeTotal = 0
|
||||
@Published private(set) var storePage = 1
|
||||
@Published private(set) var storeHasMore = false
|
||||
|
||||
private(set) var writeOffOrders: [WriteOffOrderItem] = []
|
||||
private(set) var writeOffTotal = 0
|
||||
private(set) var writeOffPage = 1
|
||||
private(set) var writeOffHasMore = false
|
||||
@Published private(set) var writeOffOrders: [WriteOffOrderItem] = []
|
||||
@Published private(set) var writeOffTotal = 0
|
||||
@Published private(set) var writeOffPage = 1
|
||||
@Published private(set) var writeOffHasMore = false
|
||||
|
||||
/// 返回去除首尾空白后的手机号搜索值。
|
||||
var normalizedPhone: String? {
|
||||
|
||||
@ -9,13 +9,13 @@ import SwiftUI
|
||||
|
||||
/// 押金订单入口页,支持订单号查询、列表分页、核销和退款。
|
||||
struct DepositOrderEntryView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = DepositOrderListViewModel()
|
||||
@StateObject private var viewModel = DepositOrderListViewModel()
|
||||
@State private var orderNumber = ""
|
||||
@State private var pendingWriteOff: DepositOrderListItem?
|
||||
@State private var pendingRefund: DepositOrderListItem?
|
||||
@ -36,7 +36,7 @@ struct DepositOrderEntryView: View {
|
||||
|
||||
Section {
|
||||
if viewModel.orders.isEmpty {
|
||||
ContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass")
|
||||
AppContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass")
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
} else {
|
||||
ForEach(viewModel.orders) { item in
|
||||
@ -239,13 +239,13 @@ private struct DepositOrderRow: View {
|
||||
|
||||
/// 押金订单详情页,展示订单基础信息和拍摄点列表。
|
||||
struct DepositOrderDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let orderNumber: String
|
||||
@State private var viewModel = DepositOrderDetailViewModel()
|
||||
@StateObject private var viewModel = DepositOrderDetailViewModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@ -275,7 +275,7 @@ struct DepositOrderDetailView: View {
|
||||
Section("拍摄详情") {
|
||||
let shootingList = detail.multiTravel?.shootingList ?? []
|
||||
if shootingList.isEmpty {
|
||||
ContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack")
|
||||
AppContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack")
|
||||
} else {
|
||||
ForEach(shootingList) { item in
|
||||
Button {
|
||||
@ -296,7 +296,7 @@ struct DepositOrderDetailView: View {
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass")
|
||||
AppContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass")
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
}
|
||||
}
|
||||
@ -363,15 +363,15 @@ private struct DepositShootingPointRow: View {
|
||||
|
||||
/// 押金拍摄信息页,展示评价、底片和成片。
|
||||
struct DepositOrderShootingInfoView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let orderNumber: String
|
||||
let scenicSpotId: Int
|
||||
let photogUid: Int
|
||||
|
||||
@State private var viewModel = DepositOrderShootingInfoViewModel()
|
||||
@StateObject private var viewModel = DepositOrderShootingInfoViewModel()
|
||||
@State private var selectedTab: DepositShootingMediaTab = .negative
|
||||
|
||||
var body: some View {
|
||||
@ -393,7 +393,7 @@ struct DepositOrderShootingInfoView: View {
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
ContentUnavailableView("暂无评价信息", systemImage: "star")
|
||||
AppContentUnavailableView("暂无评价信息", systemImage: "star")
|
||||
}
|
||||
}
|
||||
|
||||
@ -410,7 +410,7 @@ struct DepositOrderShootingInfoView: View {
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack")
|
||||
AppContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack")
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
}
|
||||
}
|
||||
@ -448,11 +448,11 @@ struct DepositOrderShootingInfoView: View {
|
||||
|
||||
/// 历史拍摄信息页,展示多点位拍摄媒体。
|
||||
struct HistoricalShootingInfoView: View {
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let orderNumber: String
|
||||
@State private var viewModel = HistoricalShootingInfoViewModel()
|
||||
@StateObject private var viewModel = HistoricalShootingInfoViewModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@ -464,7 +464,7 @@ struct HistoricalShootingInfoView: View {
|
||||
|
||||
Section("历史拍摄") {
|
||||
if viewModel.spots.isEmpty {
|
||||
ContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle")
|
||||
AppContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle")
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
} else {
|
||||
ForEach(viewModel.spots) { spot in
|
||||
@ -565,7 +565,7 @@ private struct OrderMediaGrid: View {
|
||||
|
||||
var body: some View {
|
||||
if files.isEmpty {
|
||||
ContentUnavailableView("暂无媒体文件", systemImage: "photo")
|
||||
AppContentUnavailableView("暂无媒体文件", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity, minHeight: 140)
|
||||
} else {
|
||||
LazyVGrid(columns: columns, spacing: AppMetrics.Spacing.small) {
|
||||
|
||||
@ -9,21 +9,21 @@ import SwiftUI
|
||||
|
||||
/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。
|
||||
struct StoreOrderDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
let item: OrderEntity
|
||||
@State private var viewModel: OrderDetailViewModel
|
||||
@State private var refundViewModel = OrderRefundViewModel()
|
||||
@StateObject private var viewModel: OrderDetailViewModel
|
||||
@StateObject private var refundViewModel = OrderRefundViewModel()
|
||||
@State private var showRefundSheet = false
|
||||
|
||||
/// 使用列表订单初始化详情页,并创建详情 ViewModel。
|
||||
init(item: OrderEntity) {
|
||||
self.item = item
|
||||
_viewModel = State(initialValue: OrderDetailViewModel(item: item))
|
||||
_viewModel = StateObject(wrappedValue: OrderDetailViewModel(item: item))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -204,7 +204,7 @@ struct StoreOrderDetailView: View {
|
||||
/// 普通订单退款表单,负责收集退款模式、金额和原因。
|
||||
private struct OrderRefundSheet: View {
|
||||
let item: OrderEntity
|
||||
@Bindable var viewModel: OrderRefundViewModel
|
||||
@ObservedObject var viewModel: OrderRefundViewModel
|
||||
let onCancel: () -> Void
|
||||
let onSubmit: () -> Void
|
||||
@FocusState private var focusedField: RefundInputField?
|
||||
@ -276,9 +276,9 @@ private enum RefundInputField: Hashable {
|
||||
|
||||
/// 核销订单详情页,展示核销列表项信息并支持再次发起核销。
|
||||
struct WriteOffOrderDetailView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
let item: WriteOffOrderItem
|
||||
@State private var verifying = false
|
||||
|
||||
@ -11,21 +11,21 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// 多点旅拍任务上传页面,负责选择打卡点和素材后提交到订单接口。
|
||||
struct MultiTravelTaskUploadView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel: MultiTravelTaskUploadViewModel
|
||||
@StateObject private var viewModel: MultiTravelTaskUploadViewModel
|
||||
@State private var showSpotPicker = false
|
||||
@State private var showCloudSelection = false
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
|
||||
/// 使用订单号初始化任务上传页面。
|
||||
init(initialOrderNumber: String) {
|
||||
_viewModel = State(initialValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber))
|
||||
_viewModel = StateObject(wrappedValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -61,7 +61,7 @@ struct MultiTravelTaskUploadView: View {
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.onChange(of: pickerItems) { _, items in
|
||||
.onChange(of: pickerItems) { items in
|
||||
Task { await importPickerItems(items) }
|
||||
}
|
||||
.alert("提示", isPresented: errorBinding) {
|
||||
|
||||
@ -9,16 +9,16 @@ import SwiftUI
|
||||
|
||||
/// 订单 Tab 根视图,展示订单管理和核销订单两个业务入口。
|
||||
struct OrdersView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = OrdersViewModel()
|
||||
@StateObject private var viewModel = OrdersViewModel()
|
||||
@State private var manualOrderNumber = ""
|
||||
@State private var showDateFilterSheet = false
|
||||
@State private var showScanner = false
|
||||
@ -62,7 +62,7 @@ struct OrdersView: View {
|
||||
entrySection
|
||||
|
||||
if currentScenicId == nil {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"缺少经营上下文",
|
||||
systemImage: "mountain.2",
|
||||
description: Text("请先在首页选择景区后查看订单。")
|
||||
@ -80,7 +80,7 @@ struct OrdersView: View {
|
||||
.padding(.bottom, AppMetrics.Spacing.pageVertical)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.onChange(of: matchedScannedOrderNumber) { _, orderNumber in
|
||||
.onChange(of: matchedScannedOrderNumber) { orderNumber in
|
||||
guard let orderNumber else { return }
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
scrollProxy.scrollTo(orderNumber, anchor: .center)
|
||||
@ -96,15 +96,15 @@ struct OrdersView: View {
|
||||
await reload()
|
||||
await consumePendingScanCodeIfNeeded()
|
||||
}
|
||||
.onChange(of: appRouter.selectedOrdersEntry) { _, entry in
|
||||
.onChange(of: appRouter.selectedOrdersEntry) { entry in
|
||||
guard viewModel.selectedEntry != entry else { return }
|
||||
viewModel.selectedEntry = entry
|
||||
Task { await reload() }
|
||||
}
|
||||
.onChange(of: appRouter.pendingOrderScanCode) { _, _ in
|
||||
.onChange(of: appRouter.pendingOrderScanCode) { _ in
|
||||
Task { await consumePendingScanCodeIfNeeded() }
|
||||
}
|
||||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||||
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||||
@ -233,7 +233,7 @@ struct OrdersView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 260)
|
||||
} else if viewModel.storeOrders.isEmpty {
|
||||
ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||||
AppContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||||
.frame(minHeight: 260)
|
||||
} else {
|
||||
ForEach(viewModel.storeOrders) { item in
|
||||
@ -306,7 +306,7 @@ struct OrdersView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 260)
|
||||
} else if viewModel.writeOffOrders.isEmpty {
|
||||
ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
|
||||
AppContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
|
||||
.frame(minHeight: 260)
|
||||
} else {
|
||||
ForEach(viewModel.writeOffOrders) { item in
|
||||
@ -735,11 +735,11 @@ private struct OrdersDateFilterSheet: View {
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
OrdersView()
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(AppRouter())
|
||||
.environment(RouterPath())
|
||||
.environment(OrdersAPI(client: APIClient()))
|
||||
.environment(ToastCenter())
|
||||
.environmentObject(AccountContext())
|
||||
.environmentObject(PermissionContext())
|
||||
.environmentObject(AppRouter())
|
||||
.environmentObject(RouterPath())
|
||||
.environment(\.ordersAPI, OrdersAPI(client: APIClient()))
|
||||
.environmentObject(ToastCenter())
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 收款模块服务协议,定义收款码和收款记录接口能力。
|
||||
@MainActor
|
||||
@ -19,10 +19,9 @@ protocol PaymentServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 收款 API,封装首页“收款”模块需要的网络请求。
|
||||
final class PaymentAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化收款 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -8,25 +8,24 @@
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 收款页 ViewModel,管理收款码加载、动态金额二维码和到账轮询。
|
||||
final class PaymentCollectionViewModel {
|
||||
var staticPayUrl = ""
|
||||
var dynamicPayUrl = ""
|
||||
var amountText = ""
|
||||
var remarkText = ""
|
||||
var currentPayUrl = ""
|
||||
var qrImage: UIImage?
|
||||
var isLoading = false
|
||||
var isPolling = false
|
||||
var errorMessage: String?
|
||||
var status: PaymentCollectionStatus = .idle
|
||||
final class PaymentCollectionViewModel: ObservableObject {
|
||||
@Published var staticPayUrl = ""
|
||||
@Published var dynamicPayUrl = ""
|
||||
@Published var amountText = ""
|
||||
@Published var remarkText = ""
|
||||
@Published var currentPayUrl = ""
|
||||
@Published var qrImage: UIImage?
|
||||
@Published var isLoading = false
|
||||
@Published var isPolling = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var status: PaymentCollectionStatus = .idle
|
||||
|
||||
private var knownRecordIDs = Set<String>()
|
||||
@Published private var knownRecordIDs = Set<String>()
|
||||
private let context = CIContext()
|
||||
private let qrFilter = CIFilter.qrCodeGenerator()
|
||||
|
||||
@ -191,12 +190,11 @@ private extension String {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 收款记录 ViewModel,管理收款记录加载、日期分组和汇总兜底。
|
||||
final class PaymentCollectionRecordViewModel {
|
||||
var groups: [PaymentCollectionRecordGroup] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
final class PaymentCollectionRecordViewModel: ObservableObject {
|
||||
@Published var groups: [PaymentCollectionRecordGroup] = []
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 加载当前景区收款记录,无景区时不请求接口。
|
||||
func load(api: PaymentServing, scenicId: Int?) async {
|
||||
|
||||
@ -11,12 +11,12 @@ import SwiftUI
|
||||
|
||||
/// 收款页,展示静态收款码、动态金额收款和收款记录入口。
|
||||
struct PaymentCollectionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PaymentAPI.self) private var paymentAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.paymentAPI) private var paymentAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = PaymentCollectionViewModel()
|
||||
@StateObject private var viewModel = PaymentCollectionViewModel()
|
||||
@State private var showingAmountSheet = false
|
||||
@State private var pollingTask: Task<Void, Never>?
|
||||
@State private var speaker = PaymentVoiceSpeaker()
|
||||
@ -56,12 +56,12 @@ struct PaymentCollectionView: View {
|
||||
.onDisappear {
|
||||
pollingTask?.cancel()
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.status) { _, status in
|
||||
.onChange(of: viewModel.status) { status in
|
||||
if case .success(let item) = status {
|
||||
speaker.speak("收款到账\(item.orderAmount)元")
|
||||
}
|
||||
@ -256,11 +256,11 @@ struct PaymentCollectionView: View {
|
||||
|
||||
/// 收款记录页,按日期展示扫码收款明细。
|
||||
struct PaymentCollectionRecordView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PaymentAPI.self) private var paymentAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.paymentAPI) private var paymentAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = PaymentCollectionRecordViewModel()
|
||||
@StateObject private var viewModel = PaymentCollectionRecordViewModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@ -304,7 +304,7 @@ struct PaymentCollectionRecordView: View {
|
||||
await viewModel.load(api: paymentAPI, scenicId: accountContext.currentScenic?.id)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message {
|
||||
toastCenter.show(message)
|
||||
}
|
||||
@ -314,9 +314,8 @@ struct PaymentCollectionRecordView: View {
|
||||
|
||||
/// 收款语音播报服务,使用系统 TTS 播报到账提示。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PaymentVoiceSpeaker {
|
||||
@ObservationIgnored private let synthesizer = AVSpeechSynthesizer()
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
|
||||
/// 播报指定中文文案。
|
||||
func speak(_ text: String) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。
|
||||
@MainActor
|
||||
@ -18,10 +18,9 @@ protocol PilotCertificationServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 飞手认证 API,封装 `/api/app/flyer` 认证相关接口。
|
||||
final class PilotCertificationAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化飞手认证 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。
|
||||
@ -17,29 +17,28 @@ protocol PilotRealNameServing {
|
||||
extension ProfileAPI: PilotRealNameServing {}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 飞手认证 ViewModel,管理审核状态、表单、验证码、证件图上传和提交。
|
||||
final class PilotCertificationViewModel {
|
||||
private(set) var flyer: FlyerDetailResponse?
|
||||
private(set) var realNameInfo: RealNameInfo?
|
||||
var name = ""
|
||||
var certType: PilotCertType = .caac
|
||||
var certNo = ""
|
||||
var certImageUrl = ""
|
||||
var startDate = Date()
|
||||
var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date()
|
||||
var droneModel = ""
|
||||
var droneSerialNo = ""
|
||||
var phone = ""
|
||||
var verifyCode = ""
|
||||
private(set) var pendingCertificateImageData: Data?
|
||||
private(set) var pendingCertificateFileName: String?
|
||||
private(set) var uploadProgress: Int?
|
||||
private(set) var loading = false
|
||||
private(set) var sendingCode = false
|
||||
private(set) var submitting = false
|
||||
private(set) var countdown = 0
|
||||
var statusMessage: String?
|
||||
final class PilotCertificationViewModel: ObservableObject {
|
||||
@Published private(set) var flyer: FlyerDetailResponse?
|
||||
@Published private(set) var realNameInfo: RealNameInfo?
|
||||
@Published var name = ""
|
||||
@Published var certType: PilotCertType = .caac
|
||||
@Published var certNo = ""
|
||||
@Published var certImageUrl = ""
|
||||
@Published var startDate = Date()
|
||||
@Published var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date()
|
||||
@Published var droneModel = ""
|
||||
@Published var droneSerialNo = ""
|
||||
@Published var phone = ""
|
||||
@Published var verifyCode = ""
|
||||
@Published private(set) var pendingCertificateImageData: Data?
|
||||
@Published private(set) var pendingCertificateFileName: String?
|
||||
@Published private(set) var uploadProgress: Int?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var sendingCode = false
|
||||
@Published private(set) var submitting = false
|
||||
@Published private(set) var countdown = 0
|
||||
@Published var statusMessage: String?
|
||||
|
||||
/// 加载实名状态和飞手认证详情,单通道失败不清空另一通道数据。
|
||||
func load(api: any PilotCertificationServing, realNameAPI: any PilotRealNameServing) async {
|
||||
|
||||
@ -12,14 +12,14 @@ import UIKit
|
||||
|
||||
/// 飞手认证页面,展示认证审核状态并支持提交/驳回后编辑。
|
||||
struct PilotCertificationView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(PilotCertificationAPI.self) private var pilotAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.pilotCertificationAPI) private var pilotAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = PilotCertificationViewModel()
|
||||
@StateObject private var viewModel = PilotCertificationViewModel()
|
||||
@State private var selectedImageItem: PhotosPickerItem?
|
||||
private let countdownTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
@ -55,7 +55,7 @@ struct PilotCertificationView: View {
|
||||
.onReceive(countdownTimer) { _ in
|
||||
viewModel.tickCountdown()
|
||||
}
|
||||
.onChange(of: selectedImageItem) { _, item in
|
||||
.onChange(of: selectedImageItem) { item in
|
||||
guard let item else { return }
|
||||
Task { await loadImage(item) }
|
||||
}
|
||||
|
||||
@ -6,13 +6,12 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。
|
||||
final class ProfileAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化个人信息 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,17 +6,16 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 账号切换 ViewModel,管理可切换账号列表、选择状态和提交状态。
|
||||
final class AccountSwitchViewModel {
|
||||
private(set) var accounts: [AccountSwitchAccount] = []
|
||||
var selectedAccountId: String?
|
||||
private(set) var loading = false
|
||||
private(set) var switching = false
|
||||
private var didLoad = false
|
||||
final class AccountSwitchViewModel: ObservableObject {
|
||||
@Published private(set) var accounts: [AccountSwitchAccount] = []
|
||||
@Published var selectedAccountId: String?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var switching = false
|
||||
@Published private var didLoad = false
|
||||
|
||||
/// 当前选中的账号。
|
||||
var selectedAccount: AccountSwitchAccount? {
|
||||
|
||||
@ -6,21 +6,20 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。
|
||||
final class ProfileViewModel {
|
||||
var userInfo: UserInfoResponse?
|
||||
var realNameInfo: RealNameInfo?
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var isEditingProfile = false
|
||||
var editingNickname = ""
|
||||
private(set) var pendingAvatarData: Data?
|
||||
private(set) var pendingAvatarFileName: String?
|
||||
private(set) var avatarUploadProgress: Int?
|
||||
final class ProfileViewModel: ObservableObject {
|
||||
@Published var userInfo: UserInfoResponse?
|
||||
@Published var realNameInfo: RealNameInfo?
|
||||
@Published var isLoading = false
|
||||
@Published var isSaving = false
|
||||
@Published var isEditingProfile = false
|
||||
@Published var editingNickname = ""
|
||||
@Published private(set) var pendingAvatarData: Data?
|
||||
@Published private(set) var pendingAvatarFileName: String?
|
||||
@Published private(set) var avatarUploadProgress: Int?
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
|
||||
@ -6,31 +6,30 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。
|
||||
final class RealNameAuthViewModel {
|
||||
private(set) var info: RealNameInfo?
|
||||
var realName = ""
|
||||
var idCardNo = ""
|
||||
var smsCode = ""
|
||||
var startDate = Date()
|
||||
var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
|
||||
var isLongValid = false
|
||||
var frontUrl = ""
|
||||
var backUrl = ""
|
||||
private(set) var pendingFrontImageData: Data?
|
||||
private(set) var pendingBackImageData: Data?
|
||||
private(set) var frontUploadProgress: Int?
|
||||
private(set) var backUploadProgress: Int?
|
||||
private var pendingFrontFileName: String?
|
||||
private var pendingBackFileName: String?
|
||||
private(set) var loading = false
|
||||
private(set) var sendingCode = false
|
||||
private(set) var submitting = false
|
||||
var statusMessage: String?
|
||||
final class RealNameAuthViewModel: ObservableObject {
|
||||
@Published private(set) var info: RealNameInfo?
|
||||
@Published var realName = ""
|
||||
@Published var idCardNo = ""
|
||||
@Published var smsCode = ""
|
||||
@Published var startDate = Date()
|
||||
@Published var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date()
|
||||
@Published var isLongValid = false
|
||||
@Published var frontUrl = ""
|
||||
@Published var backUrl = ""
|
||||
@Published private(set) var pendingFrontImageData: Data?
|
||||
@Published private(set) var pendingBackImageData: Data?
|
||||
@Published private(set) var frontUploadProgress: Int?
|
||||
@Published private(set) var backUploadProgress: Int?
|
||||
@Published private var pendingFrontFileName: String?
|
||||
@Published private var pendingBackFileName: String?
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var sendingCode = false
|
||||
@Published private(set) var submitting = false
|
||||
@Published var statusMessage: String?
|
||||
|
||||
/// 拉取实名认证信息,并回填表单。
|
||||
func load(api: ProfileAPI) async throws {
|
||||
|
||||
@ -9,19 +9,19 @@ import SwiftUI
|
||||
|
||||
/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。
|
||||
struct AccountSwitchView: View {
|
||||
@Environment(AppSession.self) private var appSession
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(AuthAPI.self) private var authAPI
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var appSession: AppSession
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.authAPI) private var authAPI
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = AccountSwitchViewModel()
|
||||
@StateObject private var viewModel = AccountSwitchViewModel()
|
||||
|
||||
private var selectedAccount: AccountSwitchAccount? {
|
||||
viewModel.selectedAccount
|
||||
@ -43,7 +43,7 @@ struct AccountSwitchView: View {
|
||||
@ViewBuilder
|
||||
private var accountList: some View {
|
||||
if viewModel.accounts.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView(
|
||||
AppContentUnavailableView(
|
||||
"暂无可切换账号",
|
||||
systemImage: "person.crop.circle.badge.exclamationmark",
|
||||
description: Text("当前登录账号下没有其他可切换账号。")
|
||||
|
||||
@ -10,9 +10,9 @@ import SwiftUI
|
||||
|
||||
/// 首页菜单调试预览页,展示所有已知菜单入口并跳过权限过滤。
|
||||
struct DebugHomeMenuPreviewView: View {
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
private let items = HomeMenuRouter.debugAllMenuItems()
|
||||
|
||||
|
||||
@ -11,20 +11,20 @@ import UIKit
|
||||
|
||||
/// 个人信息页视图,展示用户资料、账号状态、实名认证状态和退出登录入口。
|
||||
struct ProfileView: View {
|
||||
@Environment(AppSession.self) private var appSession
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(AuthSessionCoordinator.self) private var authSessionCoordinator
|
||||
@EnvironmentObject private var appSession: AppSession
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.authSessionCoordinator) private var authSessionCoordinator
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = ProfileViewModel()
|
||||
@StateObject private var viewModel = ProfileViewModel()
|
||||
@State private var showsPasswordSheet = false
|
||||
@State private var showsLogoutConfirm = false
|
||||
@State private var pickedAvatarItem: PhotosPickerItem?
|
||||
@ -74,13 +74,13 @@ struct ProfileView: View {
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.onChange(of: viewModel.isEditingProfile) { _, isEditing in
|
||||
.onChange(of: viewModel.isEditingProfile) { isEditing in
|
||||
guard isEditing else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
nicknameFocused = true
|
||||
}
|
||||
}
|
||||
.onChange(of: pickedAvatarItem) { _, item in
|
||||
.onChange(of: pickedAvatarItem) { item in
|
||||
Task { await prepareAvatarImage(from: item) }
|
||||
}
|
||||
}
|
||||
@ -662,14 +662,14 @@ private struct PasswordUpdateSheet: View {
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ProfileView()
|
||||
.environment(AppSession())
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(ScenicSpotContext())
|
||||
.environment(AppRouter())
|
||||
.environment(ToastCenter())
|
||||
.environment(ProfileAPI(client: APIClient()))
|
||||
.environment(OSSUploadService(configService: UploadAPI(client: APIClient())))
|
||||
.environment(AuthSessionCoordinator())
|
||||
.environmentObject(AppSession())
|
||||
.environmentObject(AccountContext())
|
||||
.environmentObject(PermissionContext())
|
||||
.environmentObject(ScenicSpotContext())
|
||||
.environmentObject(AppRouter())
|
||||
.environmentObject(ToastCenter())
|
||||
.environment(\.profileAPI, ProfileAPI(client: APIClient()))
|
||||
.environment(\.ossUploadService, OSSUploadService(configService: UploadAPI(client: APIClient())))
|
||||
.environment(\.authSessionCoordinator, AuthSessionCoordinator())
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,12 +11,12 @@ import UIKit
|
||||
|
||||
/// 实名认证页面,展示审核状态并允许提交基础实名资料。
|
||||
struct RealNameAuthView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ProfileAPI.self) private var profileAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.profileAPI) private var profileAPI
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = RealNameAuthViewModel()
|
||||
@StateObject private var viewModel = RealNameAuthViewModel()
|
||||
@State private var pickedFrontItem: PhotosPickerItem?
|
||||
@State private var pickedBackItem: PhotosPickerItem?
|
||||
|
||||
@ -38,10 +38,10 @@ struct RealNameAuthView: View {
|
||||
.task {
|
||||
await loadInfo()
|
||||
}
|
||||
.onChange(of: pickedFrontItem) { _, item in
|
||||
.onChange(of: pickedFrontItem) { item in
|
||||
Task { await prepareIdentityImage(from: item, side: .front) }
|
||||
}
|
||||
.onChange(of: pickedBackItem) { _, item in
|
||||
.onChange(of: pickedBackItem) { item in
|
||||
Task { await prepareIdentityImage(from: item, side: .back) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ enum SettingsDisplayPolicy {
|
||||
|
||||
/// 设置中心页面,展示关于我们、版本、下载链接和协议入口。
|
||||
struct SettingsCenterView: View {
|
||||
@Environment(RouterPath.self) private var router
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@State private var copiedDownloadLink = false
|
||||
|
||||
private var versionText: String {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 项目服务协议,抽象摄影师项目和店铺项目接口以便 ViewModel 测试替换。
|
||||
@MainActor
|
||||
@ -56,9 +56,8 @@ protocol ProjectServing {
|
||||
|
||||
/// 项目 API,封装项目管理、店铺项目管理相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectAPI: ProjectServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化项目 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 项目表单模式,区分新建和编辑。
|
||||
enum ProjectEditorMode: Equatable {
|
||||
@ -48,18 +48,17 @@ enum ProjectEditorError: LocalizedError, Equatable {
|
||||
|
||||
/// 摄影师项目管理 ViewModel,负责项目列表、分页、详情和删除。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectManagementViewModel {
|
||||
private(set) var items: [PhotographerProjectItem] = []
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var hasMore = false
|
||||
private(set) var selectedDetail: PhotographerProjectDetailResponse?
|
||||
var searchText = ""
|
||||
var errorMessage: String?
|
||||
final class ProjectManagementViewModel: ObservableObject {
|
||||
@Published private(set) var items: [PhotographerProjectItem] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var loadingMore = false
|
||||
@Published private(set) var hasMore = false
|
||||
@Published private(set) var selectedDetail: PhotographerProjectDetailResponse?
|
||||
@Published var searchText = ""
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private var page = 1
|
||||
private var total = 0
|
||||
@Published private var page = 1
|
||||
@Published private var total = 0
|
||||
private let pageSize = 10
|
||||
|
||||
/// 重新加载摄影师项目列表。
|
||||
@ -156,47 +155,54 @@ final class ProjectManagementViewModel {
|
||||
|
||||
/// 项目编辑 ViewModel,负责摄影师项目表单、图片上传和提交。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProjectEditorViewModel {
|
||||
var name = ""
|
||||
var descriptionText = ""
|
||||
var price = ""
|
||||
var otPrice = ""
|
||||
var deposit = ""
|
||||
var attrLabelText = ""
|
||||
var materialNum = "1"
|
||||
var photoNum = "1"
|
||||
var videoNum = "1"
|
||||
var selectedSpotIds: Set<Int> = []
|
||||
var coverImage: ProjectLocalImage?
|
||||
var carouselImages: [ProjectLocalImage] = []
|
||||
var existingCoverURL = ""
|
||||
var existingCarouselURLs: [String] = []
|
||||
private(set) var submitting = false
|
||||
private(set) var uploadProgress = 0
|
||||
var errorMessage: String?
|
||||
final class ProjectEditorViewModel: ObservableObject {
|
||||
@Published var name = ""
|
||||
@Published var descriptionText = ""
|
||||
@Published var price = ""
|
||||
@Published var otPrice = ""
|
||||
@Published var deposit = ""
|
||||
@Published var attrLabelText = ""
|
||||
@Published var materialNum = "1"
|
||||
@Published var photoNum = "1"
|
||||
@Published var videoNum = "1"
|
||||
@Published var selectedSpotIds: Set<Int> = []
|
||||
@Published var coverImage: ProjectLocalImage?
|
||||
@Published var carouselImages: [ProjectLocalImage] = []
|
||||
@Published var existingCoverURL = ""
|
||||
@Published var existingCarouselURLs: [String] = []
|
||||
@Published private(set) var submitting = false
|
||||
@Published private(set) var uploadProgress = 0
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let mode: ProjectEditorMode
|
||||
private var mode: ProjectEditorMode
|
||||
|
||||
/// 初始化摄影师项目表单,并在编辑模式下回填详情。
|
||||
init(mode: ProjectEditorMode) {
|
||||
self.mode = mode
|
||||
if case let .edit(detail) = mode {
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
price = detail.price
|
||||
otPrice = detail.otPrice
|
||||
deposit = detail.priceDeposit
|
||||
attrLabelText = detail.attrLabel.joined(separator: ",")
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
selectedSpotIds = Set(detail.scenicList.map(\.id))
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
apply(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ detail: PhotographerProjectDetailResponse) {
|
||||
mode = .edit(detail)
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
price = detail.price
|
||||
otPrice = detail.otPrice
|
||||
deposit = detail.priceDeposit
|
||||
attrLabelText = detail.attrLabel.joined(separator: ",")
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
selectedSpotIds = Set(detail.scenicList.map(\.id))
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
coverImage = nil
|
||||
carouselImages = []
|
||||
}
|
||||
|
||||
/// 提交摄影师项目,必要时先上传封面和轮播图。
|
||||
func submit(
|
||||
scenicId: Int?,
|
||||
@ -304,14 +310,13 @@ final class ProjectEditorViewModel {
|
||||
|
||||
/// 店铺项目管理 ViewModel,负责店铺项目列表、筛选、详情和删除。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class StoreProjectManagementViewModel {
|
||||
private(set) var items: [PhotographerProjectItem] = []
|
||||
private(set) var loading = false
|
||||
private(set) var selectedDetail: PhotographerProjectDetailResponse?
|
||||
var searchText = ""
|
||||
var selectedType = 0
|
||||
var errorMessage: String?
|
||||
final class StoreProjectManagementViewModel: ObservableObject {
|
||||
@Published private(set) var items: [PhotographerProjectItem] = []
|
||||
@Published private(set) var loading = false
|
||||
@Published private(set) var selectedDetail: PhotographerProjectDetailResponse?
|
||||
@Published var searchText = ""
|
||||
@Published var selectedType = 0
|
||||
@Published var errorMessage: String?
|
||||
|
||||
/// 根据本地筛选条件返回展示项目。
|
||||
var filteredItems: [PhotographerProjectItem] {
|
||||
@ -364,51 +369,58 @@ final class StoreProjectManagementViewModel {
|
||||
|
||||
/// 店铺项目编辑 ViewModel,负责多点位和押金项目表单提交。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class StoreProjectEditorViewModel {
|
||||
var projectType = 19
|
||||
var scenicList: [StoreManagerScenicItem] = []
|
||||
var storeList: [StoreItem] = []
|
||||
var selectedScenicIds: Set<Int> = []
|
||||
var selectedStoreId: Int?
|
||||
var name = ""
|
||||
var descriptionText = ""
|
||||
var coverImage: ProjectLocalImage?
|
||||
var carouselImages: [ProjectLocalImage] = []
|
||||
var existingCoverURL = ""
|
||||
var existingCarouselURLs: [String] = []
|
||||
var settleSpotNum = "1"
|
||||
var projectPrice = ""
|
||||
var priceMaterial = ""
|
||||
var pricePhoto = ""
|
||||
var priceVideo = ""
|
||||
var priceDeposit = ""
|
||||
var materialNum = "1"
|
||||
var photoNum = "1"
|
||||
var videoNum = "1"
|
||||
private(set) var submitting = false
|
||||
var errorMessage: String?
|
||||
final class StoreProjectEditorViewModel: ObservableObject {
|
||||
@Published var projectType = 19
|
||||
@Published var scenicList: [StoreManagerScenicItem] = []
|
||||
@Published var storeList: [StoreItem] = []
|
||||
@Published var selectedScenicIds: Set<Int> = []
|
||||
@Published var selectedStoreId: Int?
|
||||
@Published var name = ""
|
||||
@Published var descriptionText = ""
|
||||
@Published var coverImage: ProjectLocalImage?
|
||||
@Published var carouselImages: [ProjectLocalImage] = []
|
||||
@Published var existingCoverURL = ""
|
||||
@Published var existingCarouselURLs: [String] = []
|
||||
@Published var settleSpotNum = "1"
|
||||
@Published var projectPrice = ""
|
||||
@Published var priceMaterial = ""
|
||||
@Published var pricePhoto = ""
|
||||
@Published var priceVideo = ""
|
||||
@Published var priceDeposit = ""
|
||||
@Published var materialNum = "1"
|
||||
@Published var photoNum = "1"
|
||||
@Published var videoNum = "1"
|
||||
@Published private(set) var submitting = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let mode: StoreProjectEditorMode
|
||||
private var mode: StoreProjectEditorMode
|
||||
|
||||
/// 初始化店铺项目表单,并在编辑模式下回填详情。
|
||||
init(mode: StoreProjectEditorMode) {
|
||||
self.mode = mode
|
||||
if case let .edit(detail) = mode {
|
||||
projectType = detail.type == 4 ? 4 : 19
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
selectedScenicIds = Set(detail.scenicList.map(\.id))
|
||||
projectPrice = detail.price
|
||||
priceDeposit = detail.priceDeposit
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
apply(detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ detail: PhotographerProjectDetailResponse) {
|
||||
mode = .edit(detail)
|
||||
projectType = detail.type == 4 ? 4 : 19
|
||||
name = detail.name
|
||||
descriptionText = detail.description
|
||||
existingCoverURL = detail.coverProject
|
||||
existingCarouselURLs = detail.coverCarousel
|
||||
selectedScenicIds = Set(detail.scenicList.map(\.id))
|
||||
projectPrice = detail.price
|
||||
priceDeposit = detail.priceDeposit
|
||||
materialNum = "\(max(detail.materialNum, 0))"
|
||||
photoNum = "\(max(detail.photoNum, 0))"
|
||||
videoNum = "\(max(detail.videoNum, 0))"
|
||||
coverImage = nil
|
||||
carouselImages = []
|
||||
}
|
||||
|
||||
/// 加载店铺项目可管理景区。
|
||||
func loadScenicList(api: any ProjectServing, userId: Int) async {
|
||||
guard scenicList.isEmpty, userId > 0 else { return }
|
||||
|
||||
@ -11,17 +11,17 @@ import UniformTypeIdentifiers
|
||||
|
||||
/// 摄影师项目管理页面,展示项目列表并提供新建、详情和删除入口。
|
||||
struct ProjectManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = ProjectManagementViewModel()
|
||||
@StateObject private var viewModel = ProjectManagementViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
filterBar
|
||||
if accountContext.currentScenic?.id == nil {
|
||||
ContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。"))
|
||||
AppContentUnavailableView("缺少景区", systemImage: "map", description: Text("请选择景区后再管理项目。"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
projectList
|
||||
@ -66,7 +66,7 @@ struct ProjectManagementView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.items.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。"))
|
||||
AppContentUnavailableView("暂无项目", systemImage: "folder", description: Text("可点击右上角创建项目。"))
|
||||
.frame(maxWidth: .infinity, minHeight: 260)
|
||||
}
|
||||
ForEach(viewModel.items) { item in
|
||||
@ -110,10 +110,10 @@ struct ProjectManagementView: View {
|
||||
|
||||
/// 店铺项目管理页面,展示店铺项目列表、筛选和新建入口。
|
||||
struct StoreProjectManagementView: View {
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@State private var viewModel = StoreProjectManagementViewModel()
|
||||
@StateObject private var viewModel = StoreProjectManagementViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -127,7 +127,7 @@ struct StoreProjectManagementView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if viewModel.filteredItems.isEmpty && !viewModel.loading {
|
||||
ContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。"))
|
||||
AppContentUnavailableView("暂无店铺项目", systemImage: "folder", description: Text("可点击右上角新建店铺项目。"))
|
||||
.frame(minHeight: 260)
|
||||
}
|
||||
}
|
||||
@ -196,7 +196,7 @@ struct StoreProjectManagementView: View {
|
||||
|
||||
/// 项目详情页面,展示项目基础信息、媒体、打卡点和摄影师摘要。
|
||||
struct ProjectDetailView: View {
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
let projectId: Int
|
||||
let storeMode: Bool
|
||||
@ -209,7 +209,7 @@ struct ProjectDetailView: View {
|
||||
if let detail {
|
||||
ProjectDetailContentView(detail: detail)
|
||||
} else if let errorMessage {
|
||||
ContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage))
|
||||
AppContentUnavailableView("加载失败", systemImage: "exclamationmark.triangle", description: Text(errorMessage))
|
||||
.frame(minHeight: 360)
|
||||
} else {
|
||||
ProgressView()
|
||||
@ -247,14 +247,14 @@ struct ProjectDetailView: View {
|
||||
/// 摄影师项目编辑页面,支持新建和编辑项目。
|
||||
struct ProjectEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
let projectId: Int?
|
||||
@State private var viewModel = ProjectEditorViewModel(mode: .create)
|
||||
@StateObject private var viewModel = ProjectEditorViewModel(mode: .create)
|
||||
@State private var coverItem: PhotosPickerItem?
|
||||
@State private var carouselItems: [PhotosPickerItem] = []
|
||||
@State private var loadingDetail = false
|
||||
@ -291,10 +291,10 @@ struct ProjectEditorView: View {
|
||||
.task {
|
||||
await prepare()
|
||||
}
|
||||
.onChange(of: coverItem) { _, newValue in
|
||||
.onChange(of: coverItem) { newValue in
|
||||
Task { await loadCover(newValue) }
|
||||
}
|
||||
.onChange(of: carouselItems) { _, newValue in
|
||||
.onChange(of: carouselItems) { newValue in
|
||||
Task { await loadCarousel(newValue) }
|
||||
}
|
||||
}
|
||||
@ -387,7 +387,7 @@ struct ProjectEditorView: View {
|
||||
defer { loadingDetail = false }
|
||||
do {
|
||||
let detail = try await projectAPI.projectDetail(id: projectId)
|
||||
viewModel = ProjectEditorViewModel(mode: .edit(detail))
|
||||
viewModel.apply(detail)
|
||||
} catch {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
@ -418,12 +418,12 @@ struct ProjectEditorView: View {
|
||||
/// 店铺项目编辑页面,支持新建和编辑店铺项目。
|
||||
struct StoreProjectEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(ProjectAPI.self) private var projectAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.projectAPI) private var projectAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
let projectId: Int?
|
||||
@State private var viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
@StateObject private var viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
@State private var coverItem: PhotosPickerItem?
|
||||
@State private var carouselItems: [PhotosPickerItem] = []
|
||||
|
||||
@ -454,10 +454,10 @@ struct StoreProjectEditorView: View {
|
||||
}
|
||||
}
|
||||
.task { await prepare() }
|
||||
.onChange(of: coverItem) { _, newValue in
|
||||
.onChange(of: coverItem) { newValue in
|
||||
Task { viewModel.coverImage = await ProjectPickerLoader.loadImage(from: newValue) }
|
||||
}
|
||||
.onChange(of: carouselItems) { _, newValue in
|
||||
.onChange(of: carouselItems) { newValue in
|
||||
Task { viewModel.carouselImages = await ProjectPickerLoader.loadImages(from: newValue) }
|
||||
}
|
||||
}
|
||||
@ -545,7 +545,7 @@ struct StoreProjectEditorView: View {
|
||||
if let projectId {
|
||||
do {
|
||||
let detail = try await projectAPI.storeManagerProjectDetail(id: projectId)
|
||||
viewModel = StoreProjectEditorViewModel(mode: .edit(detail))
|
||||
viewModel.apply(detail)
|
||||
} catch {
|
||||
viewModel.errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 打卡点服务协议,抽象列表、详情和增删改接口以便单元测试替换。
|
||||
@MainActor
|
||||
@ -29,9 +29,8 @@ protocol PunchPointServing {
|
||||
|
||||
/// 打卡点 API,负责封装旧工程打卡点管理相关接口。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PunchPointAPI: PunchPointServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化打卡点 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,21 +6,20 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 打卡点列表 ViewModel,负责筛选、分页、详情加载和删除刷新。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PunchPointListViewModel {
|
||||
var selectedFilter: PunchPointFilter = .all
|
||||
var items: [PunchPointItem] = []
|
||||
var selectedDetail: PunchPointItem?
|
||||
var errorMessage: String?
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var total = 0
|
||||
final class PunchPointListViewModel: ObservableObject {
|
||||
@Published var selectedFilter: PunchPointFilter = .all
|
||||
@Published var items: [PunchPointItem] = []
|
||||
@Published var selectedDetail: PunchPointItem?
|
||||
@Published var errorMessage: String?
|
||||
@Published var isLoading = false
|
||||
@Published var isLoadingMore = false
|
||||
@Published var total = 0
|
||||
|
||||
private var page = 1
|
||||
@Published private var page = 1
|
||||
private let pageSize = 20
|
||||
|
||||
/// 是否还存在下一页数据。
|
||||
@ -121,35 +120,41 @@ final class PunchPointListViewModel {
|
||||
|
||||
/// 打卡点编辑 ViewModel,负责新增、编辑、表单校验和 OSS 图片上传。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PunchPointEditorViewModel {
|
||||
var name = ""
|
||||
var description = ""
|
||||
var address = ""
|
||||
var latitudeText = ""
|
||||
var longitudeText = ""
|
||||
var scenicSpotText = ""
|
||||
var remoteImages: [String] = []
|
||||
var localImages: [PunchPointLocalImage] = []
|
||||
var errorMessage: String?
|
||||
var isSubmitting = false
|
||||
final class PunchPointEditorViewModel: ObservableObject {
|
||||
@Published var name = ""
|
||||
@Published var description = ""
|
||||
@Published var address = ""
|
||||
@Published var latitudeText = ""
|
||||
@Published var longitudeText = ""
|
||||
@Published var scenicSpotText = ""
|
||||
@Published var remoteImages: [String] = []
|
||||
@Published var localImages: [PunchPointLocalImage] = []
|
||||
@Published var errorMessage: String?
|
||||
@Published var isSubmitting = false
|
||||
|
||||
let editingItem: PunchPointItem?
|
||||
private(set) var editingItem: PunchPointItem?
|
||||
|
||||
/// 初始化编辑 ViewModel,可传入已有打卡点作为编辑表单初值。
|
||||
init(item: PunchPointItem? = nil) {
|
||||
editingItem = item
|
||||
if let item {
|
||||
name = item.name
|
||||
description = item.description
|
||||
address = item.region?.address ?? ""
|
||||
latitudeText = item.region.map { String($0.lat) } ?? ""
|
||||
longitudeText = item.region.map { String($0.lot) } ?? ""
|
||||
scenicSpotText = item.scenicSpotStr
|
||||
remoteImages = item.guideImages
|
||||
apply(item)
|
||||
}
|
||||
}
|
||||
|
||||
/// 回填编辑详情,保留同一个 ObservableObject 实例。
|
||||
func apply(_ item: PunchPointItem) {
|
||||
editingItem = item
|
||||
name = item.name
|
||||
description = item.description
|
||||
address = item.region?.address ?? ""
|
||||
latitudeText = item.region.map { String($0.lat) } ?? ""
|
||||
longitudeText = item.region.map { String($0.lot) } ?? ""
|
||||
scenicSpotText = item.scenicSpotStr
|
||||
remoteImages = item.guideImages
|
||||
localImages = []
|
||||
}
|
||||
|
||||
/// 设置经纬度和地址,通常由定位或地图选点回填。
|
||||
func applyLocation(latitude: Double, longitude: Double, address: String) {
|
||||
latitudeText = String(format: "%.6f", latitude)
|
||||
|
||||
@ -11,15 +11,15 @@ import UIKit
|
||||
|
||||
/// 打卡点列表页面,展示当前景区下的打卡点并提供新增入口。
|
||||
struct PunchPointListView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(PunchPointAPI.self) private var punchPointAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.punchPointAPI) private var punchPointAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = PunchPointListViewModel()
|
||||
@StateObject private var viewModel = PunchPointListViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@ -58,7 +58,7 @@ struct PunchPointListView: View {
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: viewModel.selectedFilter) { _, newValue in
|
||||
.onChange(of: viewModel.selectedFilter) { newValue in
|
||||
Task {
|
||||
await globalLoading.withOptionalLoading(currentScenicId != nil) {
|
||||
await viewModel.selectFilter(newValue, scenicId: currentScenicId, api: punchPointAPI)
|
||||
@ -116,15 +116,15 @@ struct PunchPointDetailView: View {
|
||||
let punchPointId: Int
|
||||
let summary: PunchPointItem?
|
||||
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(PunchPointAPI.self) private var punchPointAPI
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.punchPointAPI) private var punchPointAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var viewModel = PunchPointListViewModel()
|
||||
@StateObject private var viewModel = PunchPointListViewModel()
|
||||
@State private var showDeleteConfirm = false
|
||||
|
||||
var item: PunchPointItem? {
|
||||
@ -275,17 +275,17 @@ struct PunchPointDetailView: View {
|
||||
struct PunchPointEditorView: View {
|
||||
let punchPointId: Int?
|
||||
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(AccountContextAPI.self) private var accountContextAPI
|
||||
@Environment(PunchPointAPI.self) private var punchPointAPI
|
||||
@Environment(OSSUploadService.self) private var uploadService
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@Environment(\.accountContextAPI) private var accountContextAPI
|
||||
@Environment(\.punchPointAPI) private var punchPointAPI
|
||||
@Environment(\.ossUploadService) private var uploadService
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
|
||||
@State private var detailLoader = PunchPointListViewModel()
|
||||
@State private var viewModel = PunchPointEditorViewModel()
|
||||
@StateObject private var detailLoader = PunchPointListViewModel()
|
||||
@StateObject private var viewModel = PunchPointEditorViewModel()
|
||||
@State private var locationProvider = ForegroundLocationProvider()
|
||||
@State private var selectedItems: [PhotosPickerItem] = []
|
||||
|
||||
@ -308,11 +308,11 @@ struct PunchPointEditorView: View {
|
||||
await globalLoading.withOptionalLoading(detailLoader.selectedDetail == nil) {
|
||||
await detailLoader.loadDetail(id: punchPointId, api: punchPointAPI)
|
||||
if let detail = detailLoader.selectedDetail {
|
||||
viewModel = PunchPointEditorViewModel(item: detail)
|
||||
viewModel.apply(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedItems) { _, items in
|
||||
.onChange(of: selectedItems) { items in
|
||||
Task { await loadPickedImages(items) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。
|
||||
@MainActor
|
||||
@ -26,10 +26,9 @@ protocol ScenicQueueServing: AnyObject {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队管理 API,封装 `/api/app/scenic-queue` 下的排队接口。
|
||||
final class ScenicQueueAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化排队管理 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,44 +6,43 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
import Photos
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队管理列表 ViewModel,负责打卡点门禁、列表分页、动作和实时事件。
|
||||
final class QueueManagementViewModel {
|
||||
var loading = false
|
||||
var loadingMore = false
|
||||
var items: [QueueItem] = []
|
||||
var scenicSpots: [ScenicSpotItem] = []
|
||||
var selectedSpotId: Int?
|
||||
var selectedListType: QueueListType = .queueing
|
||||
var queueCount = 0
|
||||
var avgWaitMin = 0.0
|
||||
var totalCount = 0
|
||||
var hasMore = false
|
||||
var page = 1
|
||||
var lastSyncTimeText = "--"
|
||||
var queueGatePassed = false
|
||||
var selectedSpotName = "--"
|
||||
var showStartShootingButton = true
|
||||
var autoCallAheadCount = 0
|
||||
var quickCallButtonEnabled = false
|
||||
var prepareCallButtonEnabled = false
|
||||
var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement?
|
||||
var errorMessage: String?
|
||||
final class QueueManagementViewModel: ObservableObject {
|
||||
@Published var loading = false
|
||||
@Published var loadingMore = false
|
||||
@Published var items: [QueueItem] = []
|
||||
@Published var scenicSpots: [ScenicSpotItem] = []
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published var selectedListType: QueueListType = .queueing
|
||||
@Published var queueCount = 0
|
||||
@Published var avgWaitMin = 0.0
|
||||
@Published var totalCount = 0
|
||||
@Published var hasMore = false
|
||||
@Published var page = 1
|
||||
@Published var lastSyncTimeText = "--"
|
||||
@Published var queueGatePassed = false
|
||||
@Published var selectedSpotName = "--"
|
||||
@Published var showStartShootingButton = true
|
||||
@Published var autoCallAheadCount = 0
|
||||
@Published var quickCallButtonEnabled = false
|
||||
@Published var prepareCallButtonEnabled = false
|
||||
@Published var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@ObservationIgnored private let pageSize = 10
|
||||
@ObservationIgnored private let socketClient = ScenicQueueSocketClient()
|
||||
@ObservationIgnored private var realtimeSpotId: Int?
|
||||
@ObservationIgnored private var realtimeScenicId: Int?
|
||||
@ObservationIgnored private var currentUserId: String?
|
||||
@ObservationIgnored private var currentScenicId: Int?
|
||||
@ObservationIgnored private var pendingRemoteCalledRecordIds = Set<Int64>()
|
||||
@ObservationIgnored private var handledRemoteCallEventIds = Set<String>()
|
||||
private let pageSize = 10
|
||||
private let socketClient = ScenicQueueSocketClient()
|
||||
@Published private var realtimeSpotId: Int?
|
||||
@Published private var realtimeScenicId: Int?
|
||||
@Published private var currentUserId: String?
|
||||
@Published private var currentScenicId: Int?
|
||||
@Published private var pendingRemoteCalledRecordIds = Set<Int64>()
|
||||
@Published private var handledRemoteCallEventIds = Set<String>()
|
||||
|
||||
/// 待处理排队数量。
|
||||
var pendingCount: Int {
|
||||
@ -417,41 +416,40 @@ final class QueueManagementViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 排队设置 ViewModel,负责设置读取、校验、保存、二维码和日志。
|
||||
final class ScenicQueueSettingsViewModel {
|
||||
var loading = false
|
||||
var message: String?
|
||||
var scenicSpots: [ScenicSpotItem] = []
|
||||
var selectedSpotId: Int?
|
||||
var photoEstimateMin = "0"
|
||||
var photoEstimateSec = "30"
|
||||
var firstAhead = "0"
|
||||
var firstSms = false
|
||||
var firstPhone = false
|
||||
var secondAhead = "0"
|
||||
var secondSms = false
|
||||
var secondPhone = false
|
||||
var broadcastIntervalSec = "50"
|
||||
var countdownThresholdSec = "15"
|
||||
var maxQueueRangeKm = "0.00"
|
||||
var localQueueLimit = "0"
|
||||
var localPassDelay = "1"
|
||||
var showStartShootingButton = true
|
||||
var autoCallAheadCount = "0"
|
||||
var queueOpen = true
|
||||
var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0)
|
||||
var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0)
|
||||
var customTtsText = ""
|
||||
var presetVoices: [String] = []
|
||||
var quickCallButtonEnabled = false
|
||||
var prepareCallButtonEnabled = false
|
||||
var logs: [ScenicQueueSettingChangeLogItem] = []
|
||||
var qrcodeURL = ""
|
||||
final class ScenicQueueSettingsViewModel: ObservableObject {
|
||||
@Published var loading = false
|
||||
@Published var message: String?
|
||||
@Published var scenicSpots: [ScenicSpotItem] = []
|
||||
@Published var selectedSpotId: Int?
|
||||
@Published var photoEstimateMin = "0"
|
||||
@Published var photoEstimateSec = "30"
|
||||
@Published var firstAhead = "0"
|
||||
@Published var firstSms = false
|
||||
@Published var firstPhone = false
|
||||
@Published var secondAhead = "0"
|
||||
@Published var secondSms = false
|
||||
@Published var secondPhone = false
|
||||
@Published var broadcastIntervalSec = "50"
|
||||
@Published var countdownThresholdSec = "15"
|
||||
@Published var maxQueueRangeKm = "0.00"
|
||||
@Published var localQueueLimit = "0"
|
||||
@Published var localPassDelay = "1"
|
||||
@Published var showStartShootingButton = true
|
||||
@Published var autoCallAheadCount = "0"
|
||||
@Published var queueOpen = true
|
||||
@Published var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0)
|
||||
@Published var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0)
|
||||
@Published var customTtsText = ""
|
||||
@Published var presetVoices: [String] = []
|
||||
@Published var quickCallButtonEnabled = false
|
||||
@Published var prepareCallButtonEnabled = false
|
||||
@Published var logs: [ScenicQueueSettingChangeLogItem] = []
|
||||
@Published var qrcodeURL = ""
|
||||
|
||||
@ObservationIgnored private let speaker = ScenicQueueSpeechService()
|
||||
@ObservationIgnored private var currentUserId: String?
|
||||
@ObservationIgnored private var currentScenicId: Int?
|
||||
private let speaker = ScenicQueueSpeechService()
|
||||
@Published private var currentUserId: String?
|
||||
@Published private var currentScenicId: Int?
|
||||
|
||||
/// 加载设置。设置页允许自动选中第一个打卡点。
|
||||
func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async {
|
||||
|
||||
@ -9,12 +9,12 @@ import SwiftUI
|
||||
|
||||
/// 排队管理首页,展示当前打卡点队列和操作入口。
|
||||
struct QueueManagementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ScenicQueueAPI.self) private var queueAPI
|
||||
@Environment(ScenicQueueRuntime.self) private var queueRuntime
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = QueueManagementViewModel()
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.scenicQueueAPI) private var queueAPI
|
||||
@EnvironmentObject private var queueRuntime: ScenicQueueRuntime
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = QueueManagementViewModel()
|
||||
@State private var pendingAction: QueueActionConfirmation?
|
||||
@State private var markTarget: QueueItem?
|
||||
@State private var processingId: Int64?
|
||||
@ -27,7 +27,7 @@ struct QueueManagementView: View {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
header
|
||||
if currentScenicId == nil {
|
||||
ContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文"))
|
||||
AppContentUnavailableView("请先选择景区", systemImage: "map", description: Text("排队管理需要当前景区上下文"))
|
||||
.frame(minHeight: 260)
|
||||
} else if !viewModel.queueGatePassed {
|
||||
gateCard
|
||||
@ -79,12 +79,12 @@ struct QueueManagementView: View {
|
||||
.refreshable {
|
||||
await reload()
|
||||
}
|
||||
.onChange(of: viewModel.remoteCallAnnouncement) { _, announcement in
|
||||
.onChange(of: viewModel.remoteCallAnnouncement) { announcement in
|
||||
if let announcement {
|
||||
toastCenter.show("远端已叫号 \(announcement.queueCode.isEmpty ? "当前游客" : announcement.queueCode)")
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { _, message in
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
if let message { toastCenter.show(message) }
|
||||
}
|
||||
}
|
||||
@ -163,7 +163,7 @@ struct QueueManagementView: View {
|
||||
ProgressView("加载中...")
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
} else if viewModel.items.isEmpty {
|
||||
ContentUnavailableView(viewModel.selectedListType.emptyText, systemImage: "person.crop.circle.badge.questionmark")
|
||||
AppContentUnavailableView(viewModel.selectedListType.emptyText, systemImage: "person.crop.circle.badge.questionmark")
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
} else {
|
||||
|
||||
@ -9,12 +9,12 @@ import SwiftUI
|
||||
|
||||
/// 排队设置页面。
|
||||
struct ScenicQueueSettingsView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicSpotContext.self) private var scenicSpotContext
|
||||
@Environment(ScenicQueueAPI.self) private var queueAPI
|
||||
@Environment(ScenicQueueRuntime.self) private var queueRuntime
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = ScenicQueueSettingsViewModel()
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var scenicSpotContext: ScenicSpotContext
|
||||
@Environment(\.scenicQueueAPI) private var queueAPI
|
||||
@EnvironmentObject private var queueRuntime: ScenicQueueRuntime
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@StateObject private var viewModel = ScenicQueueSettingsViewModel()
|
||||
@State private var selectedTab: ScenicQueueSettingsTab = .basic
|
||||
@State private var qrcodeURL: String?
|
||||
@State private var saving = false
|
||||
@ -75,7 +75,7 @@ struct ScenicQueueSettingsView: View {
|
||||
await viewModel.load(api: queueAPI, scenicId: currentScenicId, userId: currentUserId, spots: scenicSpotContext.spots)
|
||||
await viewModel.loadSettingChangeLogs(api: queueAPI, scenicId: currentScenicId)
|
||||
}
|
||||
.onChange(of: viewModel.message) { _, message in
|
||||
.onChange(of: viewModel.message) { message in
|
||||
if let message { toastCenter.show(message) }
|
||||
}
|
||||
}
|
||||
@ -106,7 +106,7 @@ struct ScenicQueueSettingsView: View {
|
||||
DatePicker("营业开始", selection: $viewModel.businessStartTime, displayedComponents: .hourAndMinute)
|
||||
DatePicker("营业结束", selection: $viewModel.businessEndTime, displayedComponents: .hourAndMinute)
|
||||
field("最大排队范围", text: $viewModel.maxQueueRangeKm, keyboard: .decimalPad, unit: "公里")
|
||||
.onChange(of: viewModel.maxQueueRangeKm) { _, value in viewModel.updateMaxQueueRange(value) }
|
||||
.onChange(of: viewModel.maxQueueRangeKm) { value in viewModel.updateMaxQueueRange(value) }
|
||||
field("排队次数限制", text: $viewModel.localQueueLimit, keyboard: .numberPad, unit: "次")
|
||||
field("过号顺延", text: $viewModel.localPassDelay, keyboard: .numberPad, unit: "位")
|
||||
field("拍摄分钟", text: $viewModel.photoEstimateMin, keyboard: .numberPad, unit: "分")
|
||||
@ -192,7 +192,7 @@ struct ScenicQueueSettingsView: View {
|
||||
}
|
||||
}
|
||||
if viewModel.logs.isEmpty {
|
||||
ContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath")
|
||||
AppContentUnavailableView("暂无配置日志", systemImage: "clock.arrow.circlepath")
|
||||
.frame(maxWidth: .infinity, minHeight: 180)
|
||||
} else {
|
||||
ForEach(viewModel.logs) { log in
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 景区权限模块服务协议,定义景区选择、权限申请和景区申请所需接口。
|
||||
@MainActor
|
||||
@ -34,10 +34,9 @@ protocol ScenicPermissionServing {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区权限 API,封装景区选择和申请相关网络请求。
|
||||
final class ScenicPermissionAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化景区权限 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 权限申请角色选项实体,表示可申请的一个业务角色。
|
||||
struct PermissionRoleOption: Equatable, Identifiable {
|
||||
@ -31,21 +31,20 @@ struct ExistingRolePermissionInfo: Equatable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 权限申请 ViewModel,负责角色/景区选择和提交审核。
|
||||
final class PermissionApplyViewModel {
|
||||
var roleOptions: [PermissionRoleOption] = []
|
||||
var selectedRoleId: Int?
|
||||
var scenicOptions: [PermissionScenicOption] = []
|
||||
var loadingScenics = false
|
||||
var scenicLoadFailed = false
|
||||
var scenicLoadFailureReason: String?
|
||||
var submitting = false
|
||||
var message: String?
|
||||
final class PermissionApplyViewModel: ObservableObject {
|
||||
@Published var roleOptions: [PermissionRoleOption] = []
|
||||
@Published var selectedRoleId: Int?
|
||||
@Published var scenicOptions: [PermissionScenicOption] = []
|
||||
@Published var loadingScenics = false
|
||||
@Published var scenicLoadFailed = false
|
||||
@Published var scenicLoadFailureReason: String?
|
||||
@Published var submitting = false
|
||||
@Published var message: String?
|
||||
|
||||
private let initialPending: RoleApplyPendingResponse?
|
||||
private var rolePermissions: [RolePermissionResponse] = []
|
||||
private var selectedIds: Set<Int> = []
|
||||
@Published private var rolePermissions: [RolePermissionResponse] = []
|
||||
@Published private var selectedIds: Set<Int> = []
|
||||
|
||||
/// 初始化权限申请 ViewModel,可传入驳回申请用于编辑回填。
|
||||
init(initialPending: RoleApplyPendingResponse? = nil) {
|
||||
@ -181,13 +180,12 @@ final class PermissionApplyViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 权限申请状态 ViewModel,负责读取审核中的或指定编号的权限申请。
|
||||
final class PermissionApplyStatusViewModel {
|
||||
var loading = false
|
||||
var pending: RoleApplyPendingResponse?
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
final class PermissionApplyStatusViewModel: ObservableObject {
|
||||
@Published var loading = false
|
||||
@Published var pending: RoleApplyPendingResponse?
|
||||
@Published var loadFailed = false
|
||||
@Published var loadFailureReason: String?
|
||||
|
||||
/// 加载权限申请状态;有申请编号时优先匹配编号,否则展示审核中或驳回记录。
|
||||
func load(api: any ScenicPermissionServing, applyCode: String?) async {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 景区申请本地图片草稿实体,表示尚未或正在上传的图片。
|
||||
struct ScenicApplicationImageDraft: Equatable, Identifiable {
|
||||
@ -29,27 +29,26 @@ struct ScenicApplicationImageDraft: Equatable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区申请 ViewModel,负责新增景区申请表单、图片上传和待审核记录回填。
|
||||
final class ScenicApplicationViewModel {
|
||||
var scenicName = ""
|
||||
var imageURLs = ""
|
||||
var localImages: [ScenicApplicationImageDraft] = []
|
||||
var selectedProvince = ""
|
||||
var selectedCity = ""
|
||||
var coopType = 1
|
||||
var remark = ""
|
||||
var agreed = false
|
||||
var submitting = false
|
||||
var message: String?
|
||||
var provinces: [ScenicAreaNode] = []
|
||||
var cities: [ScenicAreaNode] = []
|
||||
var pending: ScenicApplicationPendingResponse?
|
||||
var loading = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var pendingLoadFailed = false
|
||||
var pendingLoadFailureReason: String?
|
||||
final class ScenicApplicationViewModel: ObservableObject {
|
||||
@Published var scenicName = ""
|
||||
@Published var imageURLs = ""
|
||||
@Published var localImages: [ScenicApplicationImageDraft] = []
|
||||
@Published var selectedProvince = ""
|
||||
@Published var selectedCity = ""
|
||||
@Published var coopType = 1
|
||||
@Published var remark = ""
|
||||
@Published var agreed = false
|
||||
@Published var submitting = false
|
||||
@Published var message: String?
|
||||
@Published var provinces: [ScenicAreaNode] = []
|
||||
@Published var cities: [ScenicAreaNode] = []
|
||||
@Published var pending: ScenicApplicationPendingResponse?
|
||||
@Published var loading = false
|
||||
@Published var loadFailed = false
|
||||
@Published var loadFailureReason: String?
|
||||
@Published var pendingLoadFailed = false
|
||||
@Published var pendingLoadFailureReason: String?
|
||||
|
||||
/// 返回当前申请是否只读;审核中的申请不可再次编辑提交。
|
||||
var isReadOnly: Bool {
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import Observation
|
||||
import Combine
|
||||
|
||||
/// 景区选择展示实体,表示列表中的一个可切换景区。
|
||||
struct ScenicSelectionItem: Equatable, Identifiable {
|
||||
@ -51,13 +51,12 @@ struct ScenicSelectionItem: Equatable, Identifiable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区选择 ViewModel,负责搜索、定位距离计算和切换景区持久化。
|
||||
final class ScenicSelectionViewModel {
|
||||
var searchQuery = ""
|
||||
var currentLocationText = "定位后获取最近景区"
|
||||
var locationWarning: String?
|
||||
private(set) var items: [ScenicSelectionItem] = []
|
||||
final class ScenicSelectionViewModel: ObservableObject {
|
||||
@Published var searchQuery = ""
|
||||
@Published var currentLocationText = "定位后获取最近景区"
|
||||
@Published var locationWarning: String?
|
||||
@Published private(set) var items: [ScenicSelectionItem] = []
|
||||
|
||||
/// 返回按搜索关键词过滤后的景区列表。
|
||||
var filteredItems: [ScenicSelectionItem] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user