From 005349f8e600f0898ba1c24c895561a3a4175ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 13 Jul 2026 11:01:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8C=96=20AppStore=20?= =?UTF-8?q?=E5=B9=B6=E5=AE=8C=E5=96=84=E7=B4=A0=E6=9D=90=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E4=B8=8E=E4=B8=AA=E4=BA=BA=E7=A9=BA=E9=97=B4=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 12 +- suixinkan/App/AppRouter.swift | 4 +- suixinkan/App/NetworkServices.swift | 2 +- suixinkan/AppDelegate.swift | 2 +- .../material_ic_add.imageset/Contents.json | 1 + .../material_ic_add.svg | 1 + .../Contents.json | 1 + .../material_ic_arrow_down.svg | 1 + .../material_ic_clear.imageset/Contents.json | 1 + .../material_ic_clear.svg | 1 + .../Contents.json | 12 + .../material_ic_download_count.svg | 3 + .../material_ic_play.imageset/Contents.json | 1 + .../material_ic_play.svg | 1 + .../material_ic_search.imageset/Contents.json | 1 + .../material_ic_search.svg | 1 + .../home_menu_material.imageset/Contents.json | 12 + .../home_menu_material.svg | 3 + .../home_menu_space.imageset/Contents.json | 17 + .../home_menu_space.svg | 1 + .../Contents.json | 17 + .../profile_avatar_default.svg | 1 + .../profile_edit.imageset/Contents.json | 17 + .../profile_edit.imageset/profile_edit.svg | 1 + suixinkan/Config/UmengBootstrap.swift | 2 +- suixinkan/Core/Networking/APIClient.swift | 71 +++ suixinkan/Core/Upload/OSSUploadService.swift | 4 +- suixinkan/DataStore/AppLocationStore.swift | 57 +++ suixinkan/DataStore/AppPaymentStore.swift | 38 ++ suixinkan/DataStore/AppPermissionStore.swift | 91 ++++ suixinkan/DataStore/AppScenicQueueStore.swift | 97 ++++ suixinkan/DataStore/AppSessionStore.swift | 233 +++++++++ suixinkan/DataStore/AppStore.swift | 416 ++-------------- .../Features/Auth/AuthSessionHelper.swift | 56 +-- .../Services/CloudTransferManager.swift | 2 +- .../CooperationAcquirerViewModel.swift | 2 +- .../Services/HomeLocationStateStore.swift | 28 +- .../Home/Services/HomeMenuCatalog.swift | 4 +- .../Home/Services/HomeRouteHandler.swift | 8 +- .../ViewModels/AllFunctionsViewModel.swift | 10 +- .../Home/ViewModels/HomeViewModel.swift | 44 +- .../PermissionApplyStatusViewModel.swift | 2 +- .../ViewModels/PermissionApplyViewModel.swift | 4 +- .../ViewModels/ScenicSelectionViewModel.swift | 12 +- .../Live/ViewModels/LiveViewModels.swift | 6 +- .../Features/Location/AMapBootstrap.swift | 2 +- .../Services/LocationReportService.swift | 8 +- .../LocationReportHistoryViewModel.swift | 2 +- .../Models/MaterialManagementModels.swift | 7 - .../MaterialManagementViewModels.swift | 82 +-- .../Orders/Utils/MainTabScanHandler.swift | 6 +- .../DepositOrderListViewModel.swift | 6 +- .../ViewModels/OrderListViewModel.swift | 8 +- .../PaymentCollectionDetailsViewModel.swift | 10 +- .../PaymentCollectionRecordViewModel.swift | 2 +- .../Features/Profile/API/ProfileAPI.swift | 11 + .../ViewModels/ProfileEditViewModel.swift | 4 +- .../ProfileSpaceSettingsViewModel.swift | 57 ++- .../Profile/ViewModels/ProfileViewModel.swift | 36 +- .../ViewModels/PunchPointViewModels.swift | 4 +- .../SampleManagementViewModels.swift | 4 +- .../Services/ScenicQueueTTSManager.swift | 2 +- ...ScenicQueueSettingChangeLogViewModel.swift | 6 +- .../ScenicQueueSettingsViewModel.swift | 42 +- .../ViewModels/ScenicQueueViewModel.swift | 42 +- .../ViewModels/StatisticsViewModel.swift | 12 +- .../Task/ViewModels/TaskAddViewModel.swift | 2 +- .../ViewModels/TaskOrderSelectViewModel.swift | 2 +- .../Storage/TravelAlbumOTGPhotoStore.swift | 8 +- .../TravelAlbumCameraImportViewModel.swift | 2 +- .../TravelAlbumEntryViewModel.swift | 2 +- .../WiredCameraTransferViewModel.swift | 6 +- .../Wallet/ViewModels/WalletViewModels.swift | 4 +- .../WildPhotographerReportViewModels.swift | 8 +- .../BindAcquirerViewController.swift | 2 +- .../CooperationAcquirerViewController.swift | 2 +- .../CooperationOrderListViewController.swift | 2 +- suixinkan/UI/Home/HomeViewController.swift | 2 +- .../UI/Home/Views/AllFunctionMenuCell.swift | 2 +- .../UI/Live/LiveManageViewController.swift | 2 +- suixinkan/UI/Login/LoginViewModel.swift | 4 +- .../MaterialDetailViewController.swift | 131 ++++- .../MaterialFormViewController.swift | 255 ++++++++-- .../MaterialListViewController.swift | 26 +- .../MaterialManagementDialogs.swift | 258 ++++++++++ .../DepositOrderDetailViewController.swift | 2 +- .../UI/Orders/OrdersViewController.swift | 2 +- .../Profile/AccountSwitchViewController.swift | 10 +- .../Profile/ProfileEditViewController.swift | 2 +- .../Profile/ProfileSpaceSettingsDialogs.swift | 448 +++++++++++++++++ .../ProfileSpaceSettingsViewController.swift | 469 +++++++++++++----- .../Profile/RealNameAuthViewController.swift | 2 +- .../WithdrawalSettingsViewController.swift | 2 +- .../ScenicApplicationViewController.swift | 2 +- suixinkan/UI/Task/TaskAddViewController.swift | 4 +- .../Task/TaskOrderSelectViewController.swift | 42 +- ...PhotographerReportListViewController.swift | 2 +- ...otographerReportSubmitViewController.swift | 4 +- .../WildReportRiskMapViewController.swift | 4 +- ...portSupplementEvidenceViewController.swift | 2 +- suixinkanTests/AMapBootstrapTests.swift | 6 +- suixinkanTests/APIClientTests.swift | 55 ++ .../AllFunctionsViewModelTests.swift | 40 +- suixinkanTests/AppStoreModuleTests.swift | 134 +++++ .../CooperationAcquirerViewModelTests.swift | 2 +- .../DepositOrderListViewModelTests.swift | 6 +- .../HomeLocationStateStoreTests.swift | 28 +- suixinkanTests/HomeViewModelTests.swift | 42 +- suixinkanTests/LocationProviderTests.swift | 2 +- .../LocationReportHistoryViewModelTests.swift | 2 +- .../LocationReportServiceTests.swift | 6 +- .../MaterialManagementViewModelTests.swift | 98 ++-- suixinkanTests/OrderListViewModelTests.swift | 10 +- ...ymentCollectionDetailsViewModelTests.swift | 10 +- ...aymentCollectionRecordViewModelTests.swift | 2 +- .../PermissionApplyViewModelTests.swift | 2 +- suixinkanTests/ProfileAvatarCropTests.swift | 47 ++ .../ProfileEditViewModelTests.swift | 2 +- .../ProfileSpaceSettingsViewModelTests.swift | 63 ++- suixinkanTests/ProfileViewModelTests.swift | 20 +- suixinkanTests/ScenicQueueFeatureTests.swift | 66 +-- .../ScenicSelectionViewModelTests.swift | 14 +- suixinkanTests/StatisticsViewModelTests.swift | 26 +- suixinkanTests/TaskAddViewModelTests.swift | 2 +- .../TaskOrderSelectViewModelTests.swift | 2 +- suixinkanTests/TravelAlbumOTGCoreTests.swift | 6 +- .../TravelAlbumViewModelTests.swift | 8 +- suixinkanTests/UmengBootstrapTests.swift | 4 +- 128 files changed, 2953 insertions(+), 1123 deletions(-) create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/material_ic_add.svg create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_arrow_down.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_arrow_down.imageset/material_ic_arrow_down.svg create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_clear.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_clear.imageset/material_ic_clear.svg create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_download_count.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_download_count.imageset/material_ic_download_count.svg create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/material_ic_play.svg create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_search.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/MaterialManagement/material_ic_search.imageset/material_ic_search.svg create mode 100644 suixinkan/Assets.xcassets/home_menu_material.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/home_menu_material.imageset/home_menu_material.svg create mode 100644 suixinkan/Assets.xcassets/home_menu_space.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/home_menu_space.imageset/home_menu_space.svg create mode 100644 suixinkan/Assets.xcassets/profile_avatar_default.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/profile_avatar_default.imageset/profile_avatar_default.svg create mode 100644 suixinkan/Assets.xcassets/profile_edit.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/profile_edit.imageset/profile_edit.svg create mode 100644 suixinkan/DataStore/AppLocationStore.swift create mode 100644 suixinkan/DataStore/AppPaymentStore.swift create mode 100644 suixinkan/DataStore/AppPermissionStore.swift create mode 100644 suixinkan/DataStore/AppScenicQueueStore.swift create mode 100644 suixinkan/DataStore/AppSessionStore.swift create mode 100644 suixinkan/UI/MaterialManagement/MaterialManagementDialogs.swift create mode 100644 suixinkan/UI/Profile/ProfileSpaceSettingsDialogs.swift create mode 100644 suixinkanTests/AppStoreModuleTests.swift create mode 100644 suixinkanTests/ProfileAvatarCropTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 5988958..faefe8e 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -59,15 +59,11 @@ }; 9A01B8E22FFB84950033C402 /* Vendor */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Vendor; sourceTree = ""; }; A1B2C3D42FFB84950033C305 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanTests; sourceTree = ""; }; @@ -336,10 +332,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks.sh\"\n"; @@ -353,10 +353,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/App/AppRouter.swift b/suixinkan/App/AppRouter.swift index c7adff6..745c0bc 100644 --- a/suixinkan/App/AppRouter.swift +++ b/suixinkan/App/AppRouter.swift @@ -15,7 +15,7 @@ enum AppRouter { /// 根据登录态创建根控制器:已登录 → 主页 Tab,未登录 → 登录页。 static func makeRootViewController() -> UIViewController { - makeViewController(for: AppStore.shared.isLoggedIn ? .mainTab : .login) + makeViewController(for: AppStore.shared.session.isLoggedIn ? .mainTab : .login) } /// 切换窗口根控制器。 @@ -25,7 +25,7 @@ enum AppRouter { /// 根据当前登录态刷新根控制器。 static func refreshRoot(on window: UIWindow?, animated: Bool = true) { - setRoot(AppStore.shared.isLoggedIn ? .mainTab : .login, on: window, animated: animated) + setRoot(AppStore.shared.session.isLoggedIn ? .mainTab : .login, on: window, animated: animated) } private static func makeViewController(for root: Root) -> UIViewController { diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index c4b223f..bc4598c 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -58,7 +58,7 @@ final class NetworkServices { uploadAPI = UploadAPI(client: client) ossUploadService = OSSUploadService(configService: uploadAPI) client.bindAuthTokenProvider { - let token = AppStore.shared.token.trimmingCharacters(in: .whitespacesAndNewlines) + let token = AppStore.shared.session.token.trimmingCharacters(in: .whitespacesAndNewlines) return token.isEmpty ? nil : token } } diff --git a/suixinkan/AppDelegate.swift b/suixinkan/AppDelegate.swift index a0d85cf..98ec6bb 100644 --- a/suixinkan/AppDelegate.swift +++ b/suixinkan/AppDelegate.swift @@ -15,7 +15,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { configureKeyboardManager() UmengBootstrap.configureIfNeeded() WeChatManager.shared.registerIfNeeded() - if AppStore.shared.privacyAgreementAccepted, !AppStore.shared.token.isEmpty { + if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty { AMapBootstrap.configureIfNeeded() } return true diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/Contents.json b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/Contents.json new file mode 100644 index 0000000..4241add --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"material_ic_add.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/material_ic_add.svg b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/material_ic_add.svg new file mode 100644 index 0000000..ae7765d --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_add.imageset/material_ic_add.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_arrow_down.imageset/Contents.json b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_arrow_down.imageset/Contents.json new file mode 100644 index 0000000..ef76a3d --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_arrow_down.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"material_ic_arrow_down.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_arrow_down.imageset/material_ic_arrow_down.svg b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_arrow_down.imageset/material_ic_arrow_down.svg new file mode 100644 index 0000000..3c9227c --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_arrow_down.imageset/material_ic_arrow_down.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_clear.imageset/Contents.json b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_clear.imageset/Contents.json new file mode 100644 index 0000000..b0a68b2 --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_clear.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"material_ic_clear.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_clear.imageset/material_ic_clear.svg b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_clear.imageset/material_ic_clear.svg new file mode 100644 index 0000000..7d82eed --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_clear.imageset/material_ic_clear.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_download_count.imageset/Contents.json b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_download_count.imageset/Contents.json new file mode 100644 index 0000000..44c4992 --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_download_count.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "material_ic_download_count.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_download_count.imageset/material_ic_download_count.svg b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_download_count.imageset/material_ic_download_count.svg new file mode 100644 index 0000000..d483f83 --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_download_count.imageset/material_ic_download_count.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/Contents.json b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/Contents.json new file mode 100644 index 0000000..337adf2 --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"material_ic_play.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/material_ic_play.svg b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/material_ic_play.svg new file mode 100644 index 0000000..cc24ebe --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_play.imageset/material_ic_play.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_search.imageset/Contents.json b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_search.imageset/Contents.json new file mode 100644 index 0000000..86f63cb --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_search.imageset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"material_ic_search.svg","idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/suixinkan/Assets.xcassets/MaterialManagement/material_ic_search.imageset/material_ic_search.svg b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_search.imageset/material_ic_search.svg new file mode 100644 index 0000000..57c0183 --- /dev/null +++ b/suixinkan/Assets.xcassets/MaterialManagement/material_ic_search.imageset/material_ic_search.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/home_menu_material.imageset/Contents.json b/suixinkan/Assets.xcassets/home_menu_material.imageset/Contents.json new file mode 100644 index 0000000..99d5166 --- /dev/null +++ b/suixinkan/Assets.xcassets/home_menu_material.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "home_menu_material.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/home_menu_material.imageset/home_menu_material.svg b/suixinkan/Assets.xcassets/home_menu_material.imageset/home_menu_material.svg new file mode 100644 index 0000000..5505710 --- /dev/null +++ b/suixinkan/Assets.xcassets/home_menu_material.imageset/home_menu_material.svg @@ -0,0 +1,3 @@ + + + diff --git a/suixinkan/Assets.xcassets/home_menu_space.imageset/Contents.json b/suixinkan/Assets.xcassets/home_menu_space.imageset/Contents.json new file mode 100644 index 0000000..84c068f --- /dev/null +++ b/suixinkan/Assets.xcassets/home_menu_space.imageset/Contents.json @@ -0,0 +1,17 @@ +{ + "images" : [ + { + "filename" : "home_menu_space.svg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/suixinkan/Assets.xcassets/home_menu_space.imageset/home_menu_space.svg b/suixinkan/Assets.xcassets/home_menu_space.imageset/home_menu_space.svg new file mode 100644 index 0000000..e2e8e1b --- /dev/null +++ b/suixinkan/Assets.xcassets/home_menu_space.imageset/home_menu_space.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/profile_avatar_default.imageset/Contents.json b/suixinkan/Assets.xcassets/profile_avatar_default.imageset/Contents.json new file mode 100644 index 0000000..4c4901a --- /dev/null +++ b/suixinkan/Assets.xcassets/profile_avatar_default.imageset/Contents.json @@ -0,0 +1,17 @@ +{ + "images" : [ + { + "filename" : "profile_avatar_default.svg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/suixinkan/Assets.xcassets/profile_avatar_default.imageset/profile_avatar_default.svg b/suixinkan/Assets.xcassets/profile_avatar_default.imageset/profile_avatar_default.svg new file mode 100644 index 0000000..d754826 --- /dev/null +++ b/suixinkan/Assets.xcassets/profile_avatar_default.imageset/profile_avatar_default.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Assets.xcassets/profile_edit.imageset/Contents.json b/suixinkan/Assets.xcassets/profile_edit.imageset/Contents.json new file mode 100644 index 0000000..78d1ae8 --- /dev/null +++ b/suixinkan/Assets.xcassets/profile_edit.imageset/Contents.json @@ -0,0 +1,17 @@ +{ + "images" : [ + { + "filename" : "profile_edit.svg", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/suixinkan/Assets.xcassets/profile_edit.imageset/profile_edit.svg b/suixinkan/Assets.xcassets/profile_edit.imageset/profile_edit.svg new file mode 100644 index 0000000..c5d6181 --- /dev/null +++ b/suixinkan/Assets.xcassets/profile_edit.imageset/profile_edit.svg @@ -0,0 +1 @@ + diff --git a/suixinkan/Config/UmengBootstrap.swift b/suixinkan/Config/UmengBootstrap.swift index 6f72d1d..35b57ca 100644 --- a/suixinkan/Config/UmengBootstrap.swift +++ b/suixinkan/Config/UmengBootstrap.swift @@ -20,7 +20,7 @@ enum UmengBootstrap { /// 在用户已同意 App 隐私政策后配置友盟 SDK,幂等。 @discardableResult static func configureIfNeeded(appStore: AppStore = .shared) -> Bool { - guard appStore.privacyAgreementAccepted else { return false } + guard appStore.session.privacyAgreementAccepted else { return false } lock.lock() defer { lock.unlock() } diff --git a/suixinkan/Core/Networking/APIClient.swift b/suixinkan/Core/Networking/APIClient.swift index 299200a..1fa3bad 100644 --- a/suixinkan/Core/Networking/APIClient.swift +++ b/suixinkan/Core/Networking/APIClient.swift @@ -146,6 +146,77 @@ final class APIClient { } } + /// 发送单文件 multipart/form-data 请求,并返回解包后的业务数据。 + func sendMultipart( + path: String, + fieldName: String = "file", + fileName: String, + mimeType: String, + data fileData: Data, + timeoutInterval: TimeInterval = 60, + tokenOverride: String? = nil + ) async throws -> Response { + let apiPath = path.hasPrefix("/") ? path : "/" + path + guard let url = URL( + string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + apiPath + ) else { + throw APIError.invalidURL + } + + let boundary = "Boundary-\(UUID().uuidString)" + var body = Data() + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append( + "Content-Disposition: form-data; name=\"\(fieldName)\"; filename=\"\(fileName)\"\r\n" + .data(using: .utf8)! + ) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + body.append(fileData) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.post.rawValue + request.timeoutInterval = timeoutInterval + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION") + request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE") + request.setValue(String(body.count), forHTTPHeaderField: "Content-Length") + + let token = tokenOverride ?? authTokenProvider?() + if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { + request.setValue(token, forHTTPHeaderField: "token") + } + request.httpBody = body + logRequest(request) + + let responseData: Data + let response: URLResponse + do { + (responseData, response) = try await session.data(for: request) + } catch is CancellationError { + logCancelled(for: request, reason: "CancellationError") + throw CancellationError() + } catch let error as URLError { + if error.code == .cancelled { + logCancelled(for: request, reason: "URLError.cancelled") + throw CancellationError() + } + throw APIError.networkFailed(networkErrorMessage(for: error)) + } catch { + throw APIError.networkFailed(error.localizedDescription) + } + + logResponse(for: request, response: response, data: responseData) + do { + try validateHTTPResponse(response, data: responseData) + return try decodeEnvelope(Response.self, from: responseData) + } catch let error as APIError { + notifySessionExpiredIfNeeded(for: error) + throw error + } + } + /// 将业务请求模型转换为 URLRequest,并注入公共 Header、token 和请求体。 private func makeURLRequest( _ apiRequest: APIRequest, diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index bb97d3a..ea283b5 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -316,7 +316,7 @@ enum OSSUploadPolicy { case "material": return "material/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "punch_point": - let userId = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) + let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines) let ownerId = userId.isEmpty ? String(scenicId) : userId return "punch_point/\(currentDate)/\(ownerId)/\(uploadId)_\(safeName)" case "scenic_apply": @@ -326,7 +326,7 @@ enum OSSUploadPolicy { case "alive_album": return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "cloud_driver": - let userId = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) + let userId = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines) let ownerId = userId.isEmpty ? String(scenicId) : userId let month = String(currentDate.dropFirst(4).prefix(2)) #if DEBUG diff --git a/suixinkan/DataStore/AppLocationStore.swift b/suixinkan/DataStore/AppLocationStore.swift new file mode 100644 index 0000000..956ed93 --- /dev/null +++ b/suixinkan/DataStore/AppLocationStore.swift @@ -0,0 +1,57 @@ +// +// AppLocationStore.swift +// suixinkan +// + +import Foundation + +/// 管理当前账号的位置上报会话与提醒偏好。 +final class AppLocationStore { + + private enum Key: String, CaseIterable { + case onlineStatus = "key_online_status" + case lastReportTime = "key_last_location_report_time" + case reminderMinutes = "key_location_reminder_minutes" + } + + private let defaults: UserDefaults + private let session: AppSessionStore + + /// 使用指定持久化容器和会话创建位置存储。 + init(defaults: UserDefaults, session: AppSessionStore) { + self.defaults = defaults + self.session = session + } + + /// 是否处于位置上报在线状态。 + var onlineStatus: Bool { + get { defaults.bool(forKey: scopedKey(.onlineStatus)) } + set { defaults.set(newValue, forKey: scopedKey(.onlineStatus)) } + } + + /// 上次位置上报时间戳,单位为毫秒。 + var lastReportTime: Int64 { + get { Int64(defaults.integer(forKey: scopedKey(.lastReportTime))) } + set { defaults.set(Int(newValue), forKey: scopedKey(.lastReportTime)) } + } + + /// 位置超时提前提醒分钟数,0 表示不提醒。 + var reminderMinutes: Int { + get { defaults.integer(forKey: scopedKey(.reminderMinutes)) } + set { defaults.set(newValue, forKey: scopedKey(.reminderMinutes)) } + } + + /// 清除上次位置上报时间。 + func clearLastReportTime() { + defaults.removeObject(forKey: scopedKey(.lastReportTime)) + } + + /// 清除当前账号的位置上报状态和偏好。 + func clear() { + Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) } + } + + private func scopedKey(_ key: Key) -> String { + "\(session.accountCachePrefix)_\(key.rawValue)" + } +} diff --git a/suixinkan/DataStore/AppPaymentStore.swift b/suixinkan/DataStore/AppPaymentStore.swift new file mode 100644 index 0000000..a5432ea --- /dev/null +++ b/suixinkan/DataStore/AppPaymentStore.swift @@ -0,0 +1,38 @@ +// +// AppPaymentStore.swift +// suixinkan +// + +import Foundation + +/// 管理当前账号的收款功能偏好。 +final class AppPaymentStore { + + private enum Key: String, CaseIterable { + case isOpenReceiveVoice = "key_is_open_receive_voice" + } + + private let defaults: UserDefaults + private let session: AppSessionStore + + /// 使用指定持久化容器和会话创建收款偏好存储。 + init(defaults: UserDefaults, session: AppSessionStore) { + self.defaults = defaults + self.session = session + } + + /// 收款到账是否开启语音播报。 + var isOpenReceiveVoice: Bool { + get { defaults.bool(forKey: scopedKey(.isOpenReceiveVoice)) } + set { defaults.set(newValue, forKey: scopedKey(.isOpenReceiveVoice)) } + } + + /// 清除当前账号的收款偏好。 + func clear() { + Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) } + } + + private func scopedKey(_ key: Key) -> String { + "\(session.accountCachePrefix)_\(key.rawValue)" + } +} diff --git a/suixinkan/DataStore/AppPermissionStore.swift b/suixinkan/DataStore/AppPermissionStore.swift new file mode 100644 index 0000000..432c0fd --- /dev/null +++ b/suixinkan/DataStore/AppPermissionStore.swift @@ -0,0 +1,91 @@ +// +// AppPermissionStore.swift +// suixinkan +// + +import Foundation + +/// 管理当前账号的角色权限、首页权限与可选景区缓存。 +final class AppPermissionStore { + + private enum Key: String, CaseIterable { + case legacyRoleId = "key_in_role_id" + case rolePermissionList = "key_role_permission_list" + case permissionItems = "key_in_permission" + case roleScenicList = "key_current_role_scenic_list" + } + + private let defaults: UserDefaults + private let session: AppSessionStore + + /// 使用指定持久化容器和会话创建权限存储。 + init(defaults: UserDefaults, session: AppSessionStore) { + self.defaults = defaults + self.session = session + } + + /// 旧版 role_id,用于权限匹配兜底。 + var legacyRoleId: Int { + get { defaults.integer(forKey: scopedKey(.legacyRoleId)) } + set { defaults.set(newValue, forKey: scopedKey(.legacyRoleId)) } + } + + /// 保存完整 role-permission 列表。 + func saveRolePermissionList(_ list: [RolePermissionResponse]) { + save(list, for: .rolePermissionList) + } + + /// 读取完整 role-permission 列表。 + func rolePermissionList() -> [RolePermissionResponse] { + load([RolePermissionResponse].self, for: .rolePermissionList) ?? [] + } + + /// 保存当前角色扁平权限。 + func savePermissionItems(_ items: [HomePermissionItem]) { + save(items, for: .permissionItems) + } + + /// 读取当前角色扁平权限。 + func permissionItems() -> [HomePermissionItem] { + load([HomePermissionItem].self, for: .permissionItems) ?? [] + } + + /// 保存当前角色可选景区列表。 + func saveRoleScenicList(_ scenicList: [ScenicInfo]) { + save(scenicList, for: .roleScenicList) + } + + /// 读取当前角色可选景区列表。 + func roleScenicList() -> [ScenicInfo] { + load([ScenicInfo].self, for: .roleScenicList) ?? [] + } + + /// 在 role-permission 列表中匹配当前账号角色。 + func matchRolePermissionItem(in list: [RolePermissionResponse]) -> RolePermissionResponse? { + RolePermissionMatcher.match( + in: list, + roleCode: session.roleCode, + roleName: session.roleName, + legacyRoleId: legacyRoleId + ) + } + + /// 清除当前账号的全部权限缓存。 + func clear() { + Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) } + } + + private func save(_ value: Value, for key: Key) { + guard let data = try? JSONEncoder().encode(value) else { return } + defaults.set(data, forKey: scopedKey(key)) + } + + private func load(_ type: Value.Type, for key: Key) -> Value? { + guard let data = defaults.data(forKey: scopedKey(key)) else { return nil } + return try? JSONDecoder().decode(type, from: data) + } + + private func scopedKey(_ key: Key) -> String { + "\(session.accountCachePrefix)_\(key.rawValue)" + } +} diff --git a/suixinkan/DataStore/AppScenicQueueStore.swift b/suixinkan/DataStore/AppScenicQueueStore.swift new file mode 100644 index 0000000..9891e76 --- /dev/null +++ b/suixinkan/DataStore/AppScenicQueueStore.swift @@ -0,0 +1,97 @@ +// +// AppScenicQueueStore.swift +// suixinkan +// + +import Foundation + +/// 管理当前账号的景区排队打卡点、播报和设置缓存。 +final class AppScenicQueueStore { + + private enum Key: String, CaseIterable { + case punchSpotId = "_scenic_queue_punch_spot_id" + case punchSpotName = "_scenic_queue_punch_spot_name" + case remark = "_scenic_queue_remark" + case settingsSnapshot = "_scenic_queue_settings_snapshot" + case useOfflineTTS = "_scenic_queue_offline_tts_v1" + case presetVoices = "_scenic_queue_preset_voices_v1" + } + + private let defaults: UserDefaults + private let session: AppSessionStore + + /// 使用指定持久化容器和会话创建景区排队存储。 + init(defaults: UserDefaults, session: AppSessionStore) { + self.defaults = defaults + self.session = session + } + + /// 已保存打卡点 ID。 + var punchSpotId: String { + get { string(for: .punchSpotId) } + set { defaults.set(newValue, forKey: scopedKey(.punchSpotId)) } + } + + /// 已保存打卡点名称。 + var punchSpotName: String { + get { string(for: .punchSpotName) } + set { defaults.set(newValue, forKey: scopedKey(.punchSpotName)) } + } + + /// 自定义播报文案。 + var remark: String { + get { string(for: .remark) } + set { defaults.set(newValue, forKey: scopedKey(.remark)) } + } + + /// 是否优先使用离线 TTS。 + var useOfflineTTS: Bool { + get { defaults.bool(forKey: scopedKey(.useOfflineTTS)) } + set { defaults.set(newValue, forKey: scopedKey(.useOfflineTTS)) } + } + + /// 当前账号是否已经保存有效打卡点。 + func hasPunchSpotSaved() -> Bool { + let id = punchSpotId.trimmingCharacters(in: .whitespacesAndNewlines) + return Int64(id).map { $0 > 0 } ?? false + } + + /// 保存排队设置快照。 + func saveSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot) { + guard let data = try? JSONEncoder().encode(snapshot) else { return } + defaults.set(data, forKey: scopedKey(.settingsSnapshot)) + } + + /// 读取排队设置快照。 + func settingsSnapshot() -> ScenicQueueSettingsSnapshot? { + guard let data = defaults.data(forKey: scopedKey(.settingsSnapshot)) else { return nil } + return try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data) + } + + /// 保存排队预设播报文案,最多 5 条。 + func savePresetVoices(_ voices: [String]) { + let normalized = voices + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(5) + defaults.set(Array(normalized), forKey: scopedKey(.presetVoices)) + } + + /// 读取排队预设播报文案。 + func presetVoices() -> [String] { + defaults.stringArray(forKey: scopedKey(.presetVoices)) ?? [] + } + + /// 清除当前账号的景区排队缓存。 + func clear() { + Key.allCases.forEach { defaults.removeObject(forKey: scopedKey($0)) } + } + + private func string(for key: Key) -> String { + defaults.string(forKey: scopedKey(key)) ?? "" + } + + private func scopedKey(_ key: Key) -> String { + "\(session.accountCachePrefix)_\(key.rawValue)" + } +} diff --git a/suixinkan/DataStore/AppSessionStore.swift b/suixinkan/DataStore/AppSessionStore.swift new file mode 100644 index 0000000..9237d57 --- /dev/null +++ b/suixinkan/DataStore/AppSessionStore.swift @@ -0,0 +1,233 @@ +// +// AppSessionStore.swift +// suixinkan +// + +import Foundation + +/// 应用账号类型,保留后端返回的未知类型以兼容后续扩展。 +enum AppAccountType: Equatable, Sendable { + case scenicUser + case storeUser + case unknown(String) + + /// 从持久化或后端原始值创建账号类型。 + init(rawValue: String) { + switch rawValue { + case "scenic_user": self = .scenicUser + case "store_user": self = .storeUser + default: self = .unknown(rawValue) + } + } + + /// 用于接口与持久化的原始字符串。 + var rawValue: String { + switch self { + case .scenicUser: "scenic_user" + case .storeUser: "store_user" + case let .unknown(value): value + } + } +} + +/// 管理登录凭证、用户资料、账号角色及当前业务上下文。 +final class AppSessionStore { + + private enum Key: String { + case token = "key_in_token" + case lastLoginUsername = "key_last_login_username" + case privacyAgreementAccepted = "key_privacy_agreement_accepted" + case userId = "key_in_user_id" + case userName = "key_in_user_name" + case realName = "key_in_real_name" + case avatar = "key_in_avatar" + case phone = "key_in_phone" + case accountType = "key_in_account_type" + case accountDisplayName = "key_in_account_display_name" + case roleCode = "key_in_role_code" + case roleName = "key_in_role_name" + case currentScenicId = "key_in_current_scenic_id" + case currentScenicName = "key_in_current_scenic_name" + case currentStoreId = "key_in_current_store_id" + } + + private let defaults: UserDefaults + + /// 使用指定 UserDefaults 创建会话存储。 + init(defaults: UserDefaults) { + self.defaults = defaults + } + + /// 登录 Token。 + var token: String { + get { string(for: .token) } + set { defaults.set(newValue, forKey: Key.token.rawValue) } + } + + /// 上次登录手机号,用于登录页恢复。 + var lastLoginUsername: String? { + get { defaults.string(forKey: Key.lastLoginUsername.rawValue) } + set { + if let newValue { + defaults.set(newValue, forKey: Key.lastLoginUsername.rawValue) + } else { + defaults.removeObject(forKey: Key.lastLoginUsername.rawValue) + } + } + } + + /// 是否已同意隐私协议。 + var privacyAgreementAccepted: Bool { + get { defaults.bool(forKey: Key.privacyAgreementAccepted.rawValue) } + set { defaults.set(newValue, forKey: Key.privacyAgreementAccepted.rawValue) } + } + + /// 当前业务用户 ID。 + var userId: String { + get { string(for: .userId) } + set { defaults.set(newValue, forKey: Key.userId.rawValue) } + } + + /// 当前用户展示名称。 + var userName: String { + get { string(for: .userName) } + set { defaults.set(newValue, forKey: Key.userName.rawValue) } + } + + /// 当前用户真实姓名。 + var realName: String { + get { string(for: .realName) } + set { defaults.set(newValue, forKey: Key.realName.rawValue) } + } + + /// 当前用户头像地址。 + var avatar: String { + get { string(for: .avatar) } + set { defaults.set(newValue, forKey: Key.avatar.rawValue) } + } + + /// 当前用户手机号。 + var phone: String { + get { string(for: .phone) } + set { defaults.set(newValue, forKey: Key.phone.rawValue) } + } + + /// 当前账号业务类型。 + var accountType: AppAccountType { + get { AppAccountType(rawValue: string(for: .accountType)) } + set { defaults.set(newValue.rawValue, forKey: Key.accountType.rawValue) } + } + + /// 当前账号展示名称。 + var accountDisplayName: String { + get { string(for: .accountDisplayName) } + set { defaults.set(newValue, forKey: Key.accountDisplayName.rawValue) } + } + + /// 后端返回的原始角色编码。 + var roleCode: String { + get { string(for: .roleCode) } + set { defaults.set(newValue, forKey: Key.roleCode.rawValue) } + } + + /// 当前角色名称。 + var roleName: String { + get { string(for: .roleName) } + set { defaults.set(newValue, forKey: Key.roleName.rawValue) } + } + + /// 当前景区 ID,0 表示未选择。 + var currentScenicId: Int { + get { Int(string(for: .currentScenicId)) ?? 0 } + set { defaults.set(newValue > 0 ? String(newValue) : "", forKey: Key.currentScenicId.rawValue) } + } + + /// 当前景区名称。 + var currentScenicName: String { + get { string(for: .currentScenicName) } + set { defaults.set(newValue, forKey: Key.currentScenicName.rawValue) } + } + + /// 当前店铺 ID,0 表示未选择。 + var currentStoreId: Int { + get { defaults.integer(forKey: Key.currentStoreId.rawValue) } + set { defaults.set(newValue, forKey: Key.currentStoreId.rawValue) } + } + + /// 当前账号缓存前缀,对齐 Android `getAccountCachePrefix`。 + var accountCachePrefix: String { + let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines) + let type = accountType.rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if uid.isEmpty { return "guest" } + if type.isEmpty { return uid } + return "\(uid)_\(type)" + } + + /// 当前解析后的业务角色。 + var currentAppRole: AppRoleCode? { + AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName) + } + + /// 当前是否为摄影师角色。 + var isPhotographerRole: Bool { + if let role = currentAppRole { + return role.isPhotographer + } + return roleName.trimmingCharacters(in: .whitespacesAndNewlines) == "摄影师" + } + + /// Token 非空时表示已登录。 + var isLoggedIn: Bool { + !token.isEmpty + } + + /// 保存登录 Token。 + func saveToken(_ token: String) { + self.token = token + } + + /// 从账号切换实体写入会话快照。 + func applyAccount(_ account: AccountSwitchAccount) { + userId = String(account.businessUserId) + accountType = AppAccountType(rawValue: account.accountType) + accountDisplayName = account.title + userName = account.subtitle + phone = account.phone + if !account.avatar.isEmpty { + avatar = account.avatar + } + currentScenicName = account.scenicName + currentScenicId = account.scenicId ?? 0 + currentStoreId = account.storeId ?? 0 + } + + /// 从用户资料接口回写展示字段。 + func applyUserInfo(_ info: UserInfoResponse) { + if !info.nickname.isEmpty { userName = info.nickname } + if !info.realName.isEmpty { realName = info.realName } + if !info.avatar.isEmpty { avatar = info.avatar } + if !info.phone.isEmpty { phone = info.phone } + if !info.roleName.isEmpty, roleName.isEmpty { roleName = info.roleName } + } + + /// 清除登录态及账号快照,保留隐私授权和上次登录用户名。 + func clearAuthenticatedSession() { + token = "" + userId = "" + userName = "" + realName = "" + avatar = "" + phone = "" + accountType = .unknown("") + accountDisplayName = "" + roleCode = "" + roleName = "" + currentScenicId = 0 + currentScenicName = "" + currentStoreId = 0 + } + + private func string(for key: Key) -> String { + defaults.string(forKey: key.rawValue) ?? "" + } +} diff --git a/suixinkan/DataStore/AppStore.swift b/suixinkan/DataStore/AppStore.swift index e572726..616316a 100644 --- a/suixinkan/DataStore/AppStore.swift +++ b/suixinkan/DataStore/AppStore.swift @@ -5,403 +5,43 @@ import Foundation -/// 本地会话存储,与 Android `AppStoreDataSource` 对齐。 +/// 应用本地存储统一入口,负责组装各功能模块并协调退出登录清理。 final class AppStore { + /// 全局应用存储。 static let shared = AppStore() - private enum Key { - static let token = "key_in_token" - static let lastLoginUsername = "key_last_login_username" - static let privacyAgreementAccepted = "key_privacy_agreement_accepted" - static let userId = "key_in_user_id" - static let userName = "key_in_user_name" - static let realName = "key_in_real_name" - static let avatar = "key_in_avatar" - static let phone = "key_in_phone" - static let accountType = "key_in_account_type" - static let accountDisplayName = "key_in_account_display_name" - static let roleCode = "key_in_role_code" - static let roleName = "key_in_role_name" - static let currentScenicId = "key_in_current_scenic_id" - static let currentScenicName = "key_in_current_scenic_name" - static let currentStoreId = "key_in_current_store_id" - static let legacyRoleId = "key_in_role_id" - static let rolePermissionList = "key_role_permission_list" - static let permissionItems = "key_in_permission" - static let roleScenicList = "key_current_role_scenic_list" - static let onlineStatus = "key_online_status" - static let lastLocationReportTime = "key_last_location_report_time" - static let locationReminderMinutes = "key_location_reminder_minutes" - static let isOpenReceiveVoice = "key_is_open_receive_voice" - static let scenicQueuePunchSpotId = "_scenic_queue_punch_spot_id" - static let scenicQueuePunchSpotName = "_scenic_queue_punch_spot_name" - static let scenicQueueRemark = "_scenic_queue_remark" - static let scenicQueueSettingsSnapshot = "_scenic_queue_settings_snapshot" - static let scenicQueueOfflineTTS = "_scenic_queue_offline_tts_v1" - static let scenicQueuePresetVoices = "_scenic_queue_preset_voices_v1" - } + /// 登录会话与当前账号存储。 + let session: AppSessionStore - private let defaults: UserDefaults + /// 角色权限与首页权限缓存。 + let permissions: AppPermissionStore + /// 位置上报会话存储。 + let location: AppLocationStore + + /// 收款功能偏好存储。 + let payment: AppPaymentStore + + /// 景区排队功能存储。 + let scenicQueue: AppScenicQueueStore + + /// 使用指定 UserDefaults 创建模块化应用存储。 init(defaults: UserDefaults = .standard) { - self.defaults = defaults + let session = AppSessionStore(defaults: defaults) + self.session = session + permissions = AppPermissionStore(defaults: defaults, session: session) + location = AppLocationStore(defaults: defaults, session: session) + payment = AppPaymentStore(defaults: defaults, session: session) + scenicQueue = AppScenicQueueStore(defaults: defaults, session: session) } - /// 登录 Token。 - var token: String { - get { defaults.string(forKey: Key.token) ?? "" } - set { defaults.set(newValue, forKey: Key.token) } - } - - /// 上次登录手机号,用于登录页恢复。 - var lastLoginUsername: String? { - get { defaults.string(forKey: Key.lastLoginUsername) } - set { - if let newValue { - defaults.set(newValue, forKey: Key.lastLoginUsername) - } else { - defaults.removeObject(forKey: Key.lastLoginUsername) - } - } - } - - /// 是否已同意隐私协议。 - var privacyAgreementAccepted: Bool { - get { defaults.bool(forKey: Key.privacyAgreementAccepted) } - set { defaults.set(newValue, forKey: Key.privacyAgreementAccepted) } - } - - var userId: String { - get { defaults.string(forKey: Key.userId) ?? "" } - set { defaults.set(newValue, forKey: Key.userId) } - } - - var userName: String { - get { defaults.string(forKey: Key.userName) ?? "" } - set { defaults.set(newValue, forKey: Key.userName) } - } - - var realName: String { - get { defaults.string(forKey: Key.realName) ?? "" } - set { defaults.set(newValue, forKey: Key.realName) } - } - - var avatar: String { - get { defaults.string(forKey: Key.avatar) ?? "" } - set { defaults.set(newValue, forKey: Key.avatar) } - } - - var phone: String { - get { defaults.string(forKey: Key.phone) ?? "" } - set { defaults.set(newValue, forKey: Key.phone) } - } - - var accountType: String { - get { defaults.string(forKey: Key.accountType) ?? "" } - set { defaults.set(newValue, forKey: Key.accountType) } - } - - var accountDisplayName: String { - get { defaults.string(forKey: Key.accountDisplayName) ?? "" } - set { defaults.set(newValue, forKey: Key.accountDisplayName) } - } - - var roleCode: String { - get { defaults.string(forKey: Key.roleCode) ?? "" } - set { defaults.set(newValue, forKey: Key.roleCode) } - } - - var roleName: String { - get { defaults.string(forKey: Key.roleName) ?? "" } - set { defaults.set(newValue, forKey: Key.roleName) } - } - - var currentScenicId: Int { - get { - let value = defaults.string(forKey: Key.currentScenicId) ?? "" - return Int(value) ?? 0 - } - set { - defaults.set(newValue > 0 ? String(newValue) : "", forKey: Key.currentScenicId) - } - } - - var currentScenicName: String { - get { defaults.string(forKey: Key.currentScenicName) ?? "" } - set { defaults.set(newValue, forKey: Key.currentScenicName) } - } - - var currentStoreId: Int { - get { defaults.integer(forKey: Key.currentStoreId) } - set { defaults.set(newValue, forKey: Key.currentStoreId) } - } - - /// 旧版 role_id,用于权限匹配兜底。 - var legacyRoleId: Int { - get { defaults.integer(forKey: accountScopedKey(Key.legacyRoleId)) } - set { defaults.set(newValue, forKey: accountScopedKey(Key.legacyRoleId)) } - } - - /// 是否在线(位置上报会话)。 - var onlineStatus: Bool { - get { defaults.bool(forKey: accountScopedKey(Key.onlineStatus)) } - set { defaults.set(newValue, forKey: accountScopedKey(Key.onlineStatus)) } - } - - /// 上次位置上报时间戳(毫秒)。 - var lastLocationReportTime: Int64 { - get { Int64(defaults.integer(forKey: accountScopedKey(Key.lastLocationReportTime))) } - set { defaults.set(Int(newValue), forKey: accountScopedKey(Key.lastLocationReportTime)) } - } - - /// 位置超时提前提醒分钟数,0 表示不提醒。 - var locationReminderMinutes: Int { - get { defaults.integer(forKey: accountScopedKey(Key.locationReminderMinutes)) } - set { defaults.set(newValue, forKey: accountScopedKey(Key.locationReminderMinutes)) } - } - - /// 收款到账是否开启语音播报,对齐 Android `getIsOpenReceiveVoice`。 - var isOpenReceiveVoice: Bool { - get { defaults.bool(forKey: accountScopedKey(Key.isOpenReceiveVoice)) } - set { defaults.set(newValue, forKey: accountScopedKey(Key.isOpenReceiveVoice)) } - } - - /// 排队管理已保存打卡点 ID。 - var scenicQueuePunchSpotId: String { - get { defaults.string(forKey: accountScopedKey(Key.scenicQueuePunchSpotId)) ?? "" } - set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueuePunchSpotId)) } - } - - /// 排队管理已保存打卡点名称。 - var scenicQueuePunchSpotName: String { - get { defaults.string(forKey: accountScopedKey(Key.scenicQueuePunchSpotName)) ?? "" } - set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueuePunchSpotName)) } - } - - /// 排队管理自定义播报文案。 - var scenicQueueRemark: String { - get { defaults.string(forKey: accountScopedKey(Key.scenicQueueRemark)) ?? "" } - set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueueRemark)) } - } - - /// 排队语音是否优先使用离线 TTS。 - var scenicQueueUseOfflineTTS: Bool { - get { defaults.bool(forKey: accountScopedKey(Key.scenicQueueOfflineTTS)) } - set { defaults.set(newValue, forKey: accountScopedKey(Key.scenicQueueOfflineTTS)) } - } - - /// 当前账号缓存前缀,对齐 Android `getAccountCachePrefix`。 - var accountCachePrefix: String { - let uid = userId.trimmingCharacters(in: .whitespacesAndNewlines) - let type = accountType.trimmingCharacters(in: .whitespacesAndNewlines) - if uid.isEmpty { return "guest" } - if type.isEmpty { return uid } - return "\(uid)_\(type)" - } - - /// 当前解析后的业务角色。 - var currentAppRole: AppRoleCode? { - AppRoleCode.fromCode(roleCode) ?? AppRoleCode.fromRoleName(roleName) - } - - /// 当前是否为摄影师角色。 - var isPhotographerRole: Bool { - if let role = currentAppRole { - return role.isPhotographer - } - let name = roleName.trimmingCharacters(in: .whitespacesAndNewlines) - return name == "摄影师" - } - - /// Token 非空视为已登录。 - var isLoggedIn: Bool { - !token.isEmpty - } - - /// 保存登录 Token。 - func saveToken(_ token: String) { - self.token = token - } - - /// 从账号切换实体写入会话快照。 - func applyAccount(_ account: AccountSwitchAccount) { - userId = String(account.businessUserId) - accountType = account.accountType - accountDisplayName = account.title - userName = account.subtitle - phone = account.phone - if !account.avatar.isEmpty { - avatar = account.avatar - } - currentScenicName = account.scenicName - currentScenicId = account.scenicId ?? 0 - currentStoreId = account.storeId ?? 0 - } - - /// 从用户资料接口回写展示字段。 - func applyUserInfo(_ info: UserInfoResponse) { - if !info.nickname.isEmpty { - userName = info.nickname - } - if !info.realName.isEmpty { - realName = info.realName - } - if !info.avatar.isEmpty { - avatar = info.avatar - } - if !info.phone.isEmpty { - phone = info.phone - } - if !info.roleName.isEmpty, roleName.isEmpty { - roleName = info.roleName - } - } - - /// 清除登录态与会话快照。 + /// 清除当前账号业务快照与登录态,保留隐私授权和上次登录用户名。 func logout() { - clearPermissionSnapshot() - token = "" - userId = "" - userName = "" - realName = "" - avatar = "" - phone = "" - accountType = "" - accountDisplayName = "" - roleCode = "" - roleName = "" - currentScenicId = 0 - currentScenicName = "" - currentStoreId = 0 - } - - /// 保存完整 role-permission 列表。 - func saveRolePermissionList(_ list: [RolePermissionResponse]) { - guard let data = try? JSONEncoder().encode(list) else { return } - defaults.set(data, forKey: accountScopedKey(Key.rolePermissionList)) - } - - /// 读取完整 role-permission 列表。 - func rolePermissionList() -> [RolePermissionResponse] { - guard let data = defaults.data(forKey: accountScopedKey(Key.rolePermissionList)), - let list = try? JSONDecoder().decode([RolePermissionResponse].self, from: data) else { - return [] - } - return list - } - - /// 保存当前角色扁平权限。 - func savePermissionItems(_ items: [HomePermissionItem]) { - guard let data = try? JSONEncoder().encode(items) else { return } - defaults.set(data, forKey: accountScopedKey(Key.permissionItems)) - } - - /// 读取当前角色扁平权限。 - func permissionItems() -> [HomePermissionItem] { - guard let data = defaults.data(forKey: accountScopedKey(Key.permissionItems)), - let items = try? JSONDecoder().decode([HomePermissionItem].self, from: data) else { - return [] - } - return items - } - - /// 保存当前角色可选景区列表。 - func saveRoleScenicList(_ scenicList: [ScenicInfo]) { - guard let data = try? JSONEncoder().encode(scenicList) else { return } - defaults.set(data, forKey: accountScopedKey(Key.roleScenicList)) - } - - /// 读取当前角色可选景区列表。 - func roleScenicList() -> [ScenicInfo] { - guard let data = defaults.data(forKey: accountScopedKey(Key.roleScenicList)), - let list = try? JSONDecoder().decode([ScenicInfo].self, from: data) else { - return [] - } - return list - } - - /// 在 role-permission 列表中匹配当前账号角色。 - func matchRolePermissionItem(in list: [RolePermissionResponse]) -> RolePermissionResponse? { - RolePermissionMatcher.match( - in: list, - roleCode: roleCode, - roleName: roleName, - legacyRoleId: legacyRoleId - ) - } - - /// 清除上次位置上报时间。 - func clearLastLocationReportTime() { - defaults.removeObject(forKey: accountScopedKey(Key.lastLocationReportTime)) - } - - /// 是否已经保存排队打卡点。 - func hasScenicQueuePunchSpotSaved() -> Bool { - let id = scenicQueuePunchSpotId.trimmingCharacters(in: .whitespacesAndNewlines) - return Int64(id).map { $0 > 0 } ?? false - } - - /// 保存排队设置快照。 - func saveScenicQueueSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot) { - guard let data = try? JSONEncoder().encode(snapshot) else { return } - defaults.set(data, forKey: accountScopedKey(Key.scenicQueueSettingsSnapshot)) - } - - /// 读取排队设置快照。 - func scenicQueueSettingsSnapshot() -> ScenicQueueSettingsSnapshot? { - guard let data = defaults.data(forKey: accountScopedKey(Key.scenicQueueSettingsSnapshot)) else { return nil } - return try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data) - } - - /// 保存排队预设播报文案,最多 5 条。 - func saveScenicQueuePresetVoices(_ voices: [String]) { - let normalized = voices - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .prefix(5) - defaults.set(Array(normalized), forKey: accountScopedKey(Key.scenicQueuePresetVoices)) - } - - /// 读取排队预设播报文案。 - func scenicQueuePresetVoices() -> [String] { - defaults.stringArray(forKey: accountScopedKey(Key.scenicQueuePresetVoices)) ?? [] - } - - /// 清除排队管理本地缓存。 - func clearScenicQueueSnapshot() { - [ - Key.scenicQueuePunchSpotId, - Key.scenicQueuePunchSpotName, - Key.scenicQueueRemark, - Key.scenicQueueSettingsSnapshot, - Key.scenicQueueOfflineTTS, - Key.scenicQueuePresetVoices, - ].forEach { defaults.removeObject(forKey: accountScopedKey($0)) } - } - - /// 清除权限与位置会话快照。 - func clearPermissionSnapshot() { - let prefix = accountScopedKey("") - let keys = [ - Key.rolePermissionList, - Key.permissionItems, - Key.roleScenicList, - Key.onlineStatus, - Key.lastLocationReportTime, - Key.locationReminderMinutes, - Key.isOpenReceiveVoice, - Key.scenicQueuePunchSpotId, - Key.scenicQueuePunchSpotName, - Key.scenicQueueRemark, - Key.scenicQueueSettingsSnapshot, - Key.scenicQueueOfflineTTS, - Key.scenicQueuePresetVoices, - ] - keys.forEach { defaults.removeObject(forKey: prefix + $0) } - } - - private func accountScopedKey(_ key: String) -> String { - "\(accountCachePrefix)_\(key)" + permissions.clear() + location.clear() + payment.clear() + scenicQueue.clear() + session.clearAuthenticatedSession() } } diff --git a/suixinkan/Features/Auth/AuthSessionHelper.swift b/suixinkan/Features/Auth/AuthSessionHelper.swift index 12522f6..7d28596 100644 --- a/suixinkan/Features/Auth/AuthSessionHelper.swift +++ b/suixinkan/Features/Auth/AuthSessionHelper.swift @@ -18,9 +18,9 @@ enum AuthSessionHelper { let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) guard !token.isEmpty else { return } - AppStore.shared.saveToken(token) - AppStore.shared.lastLoginUsername = username - AppStore.shared.privacyAgreementAccepted = privacyAgreementAccepted + AppStore.shared.session.saveToken(token) + AppStore.shared.session.lastLoginUsername = username + AppStore.shared.session.privacyAgreementAccepted = privacyAgreementAccepted applyAccountSession(from: selectedAccount, in: response) if privacyAgreementAccepted { @@ -39,14 +39,14 @@ enum AuthSessionHelper { let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) guard !token.isEmpty else { return } - AppStore.shared.saveToken(token) + AppStore.shared.session.saveToken(token) applyAccountSession(from: account, in: response) NotificationCenter.default.post(name: NotificationName.accountDidSwitch, object: nil) } /// 将 v9 响应中的角色与账号展示信息写入 AppStore。 static func applyAccountSession(from account: AccountSwitchAccount, in response: V9AuthResponse) { - AppStore.shared.applyAccount(account) + AppStore.shared.session.applyAccount(account) if let scenicUser = response.scenicUsers.first(where: { matches($0, account: account) }) { applyScenicUser(scenicUser) @@ -67,43 +67,43 @@ enum AuthSessionHelper { } private static func applyScenicUser(_ user: V9ScenicUser) { - AppStore.shared.userId = String(user.businessUserId) - AppStore.shared.accountType = V9ScenicUser.accountTypeValue - AppStore.shared.accountDisplayName = firstNonEmpty( + AppStore.shared.session.userId = String(user.businessUserId) + AppStore.shared.session.accountType = .scenicUser + AppStore.shared.session.accountDisplayName = firstNonEmpty( user.scenicName, user.nickname, user.realName, user.username ) - AppStore.shared.userName = firstNonEmpty(user.nickname, user.username, user.realName) - AppStore.shared.realName = firstNonEmpty(user.realName, user.nickname, user.username) - AppStore.shared.phone = user.phone - AppStore.shared.currentScenicId = user.scenicId - AppStore.shared.currentScenicName = user.scenicName - AppStore.shared.currentStoreId = 0 + AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName) + AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username) + AppStore.shared.session.phone = user.phone + AppStore.shared.session.currentScenicId = user.scenicId + AppStore.shared.session.currentScenicName = user.scenicName + AppStore.shared.session.currentStoreId = 0 if !user.appRoleCode.isEmpty { - AppStore.shared.roleCode = user.appRoleCode + AppStore.shared.session.roleCode = user.appRoleCode } if !user.appRoleName.isEmpty { - AppStore.shared.roleName = user.appRoleName + AppStore.shared.session.roleName = user.appRoleName } } private static func applyStoreUser(_ user: V9StoreUser) { - AppStore.shared.userId = String(user.businessUserId) - AppStore.shared.accountType = V9StoreUser.accountTypeValue - AppStore.shared.accountDisplayName = firstNonEmpty( + AppStore.shared.session.userId = String(user.businessUserId) + AppStore.shared.session.accountType = .storeUser + AppStore.shared.session.accountDisplayName = firstNonEmpty( user.storeName, user.scenicName, user.realName, user.userName, user.username ) - AppStore.shared.userName = firstNonEmpty(user.userName, user.username, user.realName) - AppStore.shared.realName = firstNonEmpty(user.realName, user.userName, user.username) - AppStore.shared.phone = user.phone - AppStore.shared.avatar = user.avatar - AppStore.shared.currentScenicId = user.scenicId - AppStore.shared.currentScenicName = user.scenicName - AppStore.shared.currentStoreId = user.storeId + AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName) + AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username) + AppStore.shared.session.phone = user.phone + AppStore.shared.session.avatar = user.avatar + AppStore.shared.session.currentScenicId = user.scenicId + AppStore.shared.session.currentScenicName = user.scenicName + AppStore.shared.session.currentStoreId = user.storeId if !user.appRoleCode.isEmpty { - AppStore.shared.roleCode = user.appRoleCode + AppStore.shared.session.roleCode = user.appRoleCode } if !user.appRoleName.isEmpty { - AppStore.shared.roleName = user.appRoleName + AppStore.shared.session.roleName = user.appRoleName } } diff --git a/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift b/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift index 7ffb546..5a1e280 100644 --- a/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift +++ b/suixinkan/Features/CloudDrive/Services/CloudTransferManager.swift @@ -14,7 +14,7 @@ final class CloudTransferManager { api: NetworkServices.shared.cloudDriveAPI, downloader: CloudURLSessionDownloader(), photoSaver: CloudPhotoLibrarySaver(), - scenicIdProvider: { AppStore.shared.currentScenicId } + scenicIdProvider: { AppStore.shared.session.currentScenicId } ) private(set) var uploadTasks: [CloudTransferTask] = [] diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift index a409a58..9724f6b 100644 --- a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift +++ b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift @@ -33,7 +33,7 @@ final class CooperationAcquirerViewModel { } func loadAcquirers(api: OrderAPI, initial: Bool = false) async { - let storeId = appStore.currentStoreId + let storeId = appStore.session.currentStoreId let storeIdParam = storeId > 0 ? String(storeId) : nil do { let response = try await api.cooperativeSalers(storeId: storeIdParam, pageSize: "20") diff --git a/suixinkan/Features/Home/Services/HomeLocationStateStore.swift b/suixinkan/Features/Home/Services/HomeLocationStateStore.swift index a08920a..b7596f4 100644 --- a/suixinkan/Features/Home/Services/HomeLocationStateStore.swift +++ b/suixinkan/Features/Home/Services/HomeLocationStateStore.swift @@ -24,8 +24,8 @@ final class HomeLocationStateStore { init(store: AppStore = .shared) { self.store = store - reminderMinutes = store.locationReminderMinutes - isOnline = store.onlineStatus + reminderMinutes = store.location.reminderMinutes + isOnline = store.location.onlineStatus } deinit { @@ -44,18 +44,18 @@ final class HomeLocationStateStore { /// 启动时恢复在线倒计时。 func restoreStateIfNeeded() { - reminderMinutes = store.locationReminderMinutes - guard store.onlineStatus else { + reminderMinutes = store.location.reminderMinutes + guard store.location.onlineStatus else { isOnline = false stopCountdown() notifyChange() return } - let lastTime = store.lastLocationReportTime + let lastTime = store.location.lastReportTime guard lastTime > 0 else { isOnline = false - store.onlineStatus = false - store.clearLastLocationReportTime() + store.location.onlineStatus = false + store.location.clearLastReportTime() stopCountdown() notifyChange() return @@ -65,7 +65,7 @@ final class HomeLocationStateStore { let elapsed = now - lastTime if elapsed >= Self.onlineDurationMillis { updateOnlineStatus(false) - store.clearLastLocationReportTime() + store.location.clearLastReportTime() notifyChange() return } @@ -81,10 +81,10 @@ final class HomeLocationStateStore { /// 更新在线状态并持久化。 func updateOnlineStatus(_ online: Bool) { isOnline = online - store.onlineStatus = online + store.location.onlineStatus = online if !online { stopCountdown() - store.clearLastLocationReportTime() + store.location.clearLastReportTime() } notifyChange() } @@ -92,16 +92,16 @@ final class HomeLocationStateStore { /// 更新提醒分钟数。 func updateReminderMinutes(_ minutes: Int) { reminderMinutes = minutes - store.locationReminderMinutes = minutes + store.location.reminderMinutes = minutes notifyChange() } /// 开始新的位置上报会话。 func startLocationReport(at timestamp: Int64 = Int64(Date().timeIntervalSince1970 * 1000)) { anchorTimestamp = timestamp - store.lastLocationReportTime = timestamp + store.location.lastReportTime = timestamp isOnline = true - store.onlineStatus = true + store.location.onlineStatus = true elapsedSeconds = 0 nextReportCountdownSeconds = Self.onlineDurationMillis / 1000 startCountdown(anchorTime: timestamp) @@ -156,7 +156,7 @@ final class HomeLocationStateStore { if remaining <= 0 { self.nextReportCountdownSeconds = 0 self.updateOnlineStatus(false) - self.store.clearLastLocationReportTime() + self.store.location.clearLastReportTime() return } self.nextReportCountdownSeconds = max(0, remaining / 1000) diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift index 6d76f04..1ecbd10 100644 --- a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -10,10 +10,10 @@ enum HomeMenuCatalog { /// 全部已知菜单项,对齐 Android `Constants.menuList`。 static let allItems: [HomeMenuItem] = [ - HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "person.crop.square.fill"), + HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "home_menu_space"), HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "creditcard.fill"), HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud.fill"), - HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "photo.on.rectangle.angled"), + HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "home_menu_material"), HomeMenuItem(uri: "task_management", title: "任务管理", iconName: "checklist"), HomeMenuItem(uri: "task_management_editor", title: "任务管理", iconName: "checklist"), HomeMenuItem(uri: "schedule_management", title: "日程管理", iconName: "calendar"), diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index b763d4e..0edc583 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -21,7 +21,7 @@ enum HomeRouteHandler { return } - if uri != "pilot_controller", uri != "wallet", AppStore.shared.currentScenicId <= 0 { + if uri != "pilot_controller", uri != "wallet", AppStore.shared.session.currentScenicId <= 0 { showToast("请先选择景区", from: viewController) return } @@ -87,7 +87,7 @@ enum HomeRouteHandler { @MainActor private static func handleOperatingArea(from viewController: UIViewController, storeItem: StoreItem?) { - guard let role = AppStore.shared.currentAppRole else { + guard let role = AppStore.shared.session.currentAppRole else { showToast("当前账号暂不支持运营区域", from: viewController) return } @@ -99,7 +99,7 @@ enum HomeRouteHandler { } showDeveloping(from: viewController) case .scenicAdmin: - guard AppStore.shared.currentScenicId > 0 else { + guard AppStore.shared.session.currentScenicId > 0 else { showToast("请先选择景区", from: viewController) return } @@ -110,7 +110,7 @@ enum HomeRouteHandler { } private static func hasCooperationOrderPermission() -> Bool { - let permissions = AppStore.shared.permissionItems() + let permissions = AppStore.shared.permissions.permissionItems() return permissions.contains { $0.uri == "cooperation_order" } || permissions.contains(where: { containsCooperationOrder(in: $0) }) } diff --git a/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift b/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift index a8ea870..7c13fe8 100644 --- a/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift @@ -27,10 +27,10 @@ final class AllFunctionsViewModel { /// 加载全部功能并按常用配置拆分。 func loadFunctions() { - let permissions = appStore.permissionItems() + let permissions = appStore.permissions.permissionItems() let allMenus = HomeMenuCatalog.visibleMenus(from: permissions) - let accountScope = appStore.accountCachePrefix - let roleCode = appStore.roleCode + let accountScope = appStore.session.accountCachePrefix + let roleCode = appStore.session.roleCode let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode) let defaultURIs = HomeCommonMenuStore.defaultCommonURIs(from: permissions) let commonURIs = savedURIs.isEmpty ? defaultURIs : savedURIs @@ -71,8 +71,8 @@ final class AllFunctionsViewModel { private func persistCommonMenus() { commonMenuStore.saveCommonURIs( commonMenus.map(\.uri), - accountScope: appStore.accountCachePrefix, - roleCode: appStore.roleCode + accountScope: appStore.session.accountCachePrefix, + roleCode: appStore.session.roleCode ) } diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index 4eb6137..d1dc349 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -94,7 +94,7 @@ final class HomeViewModel { /// 拉取 role-permission 并刷新菜单。 func loadPermissions(api: HomeAPI, force: Bool = false) async { - if !force, !appStore.permissionItems().isEmpty { + if !force, !appStore.permissions.permissionItems().isEmpty { rebuildCommonMenus() await loadStoreListIfNeeded(api: api) notifyStateChange() @@ -104,21 +104,21 @@ final class HomeViewModel { do { let rolePermissionList = try await api.rolePermissions() if rolePermissionList.isEmpty { - appStore.savePermissionItems([]) + appStore.permissions.savePermissionItems([]) showPermissionDialog = true rebuildCommonMenus() notifyStateChange() return } - appStore.saveRolePermissionList(rolePermissionList) - let matched = appStore.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0] + appStore.permissions.saveRolePermissionList(rolePermissionList) + let matched = appStore.permissions.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0] if !matched.role.name.isEmpty { - appStore.roleName = matched.role.name + appStore.session.roleName = matched.role.name } - appStore.saveRoleScenicList(matched.scenic) + appStore.permissions.saveRoleScenicList(matched.scenic) let permissions = matched.role.permission.map(HomePermissionItem.init) - appStore.savePermissionItems(permissions) + appStore.permissions.savePermissionItems(permissions) showPermissionDialog = false refreshLocalDisplayState() rebuildCommonMenus() @@ -127,7 +127,7 @@ final class HomeViewModel { } catch is CancellationError { return } catch { - appStore.savePermissionItems([]) + appStore.permissions.savePermissionItems([]) showPermissionDialog = true rebuildCommonMenus() onShowMessage?(error.localizedDescription) @@ -142,7 +142,7 @@ final class HomeViewModel { notifyStateChange() return } - guard appStore.currentScenicId > 0 else { + guard appStore.session.currentScenicId > 0 else { storeItem = nil notifyStateChange() return @@ -150,13 +150,13 @@ final class HomeViewModel { do { let response = try await api.storeList() - let scenicId = appStore.currentScenicId - let savedStoreId = appStore.currentStoreId + let scenicId = appStore.session.currentScenicId + let savedStoreId = appStore.session.currentStoreId let savedMatch = savedStoreId > 0 ? response.list.first { $0.id == savedStoreId } : nil let matched = savedMatch ?? response.list.first { $0.scenicId == scenicId } storeItem = matched if let matched { - appStore.currentStoreId = matched.id + appStore.session.currentStoreId = matched.id } notifyStateChange() } catch is CancellationError { @@ -173,7 +173,7 @@ final class HomeViewModel { showScenicDialog = false return } - let hasScenic = appStore.currentScenicId > 0 + let hasScenic = appStore.session.currentScenicId > 0 showScenicDialog = !hasScenic if showScenicDialog || dismissedLocationTimeoutDialog { showLocationTimeoutDialog = false @@ -196,7 +196,7 @@ final class HomeViewModel { notifyStateChange() return } - let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines) + let staffId = appStore.session.userId.trimmingCharacters(in: .whitespacesAndNewlines) guard !staffId.isEmpty else { showLocationTimeoutDialog = true notifyStateChange() @@ -225,25 +225,25 @@ final class HomeViewModel { /// 刷新景区名与角色展示状态,对齐 Android `initCurrentScenicName`。 func refreshLocalDisplayState() { - let scenicId = appStore.currentScenicId - let scenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) + let scenicId = appStore.session.currentScenicId + let scenicName = appStore.session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) if scenicId > 0, !scenicName.isEmpty { currentScenicName = scenicName } else { currentScenicName = "" - appStore.currentScenicId = 0 - appStore.currentScenicName = "" + appStore.session.currentScenicId = 0 + appStore.session.currentScenicName = "" } - currentAppRole = appStore.currentAppRole + currentAppRole = appStore.session.currentAppRole isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false } /// 重建常用应用列表。 func rebuildCommonMenus() { - let permissions = appStore.permissionItems() + let permissions = appStore.permissions.permissionItems() let allMenus = HomeMenuCatalog.visibleMenus(from: permissions) - let accountScope = appStore.accountCachePrefix - let roleCode = appStore.roleCode + let accountScope = appStore.session.accountCachePrefix + let roleCode = appStore.session.roleCode let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode) if savedURIs.isEmpty, !permissions.isEmpty { diff --git a/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift b/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift index f98df6c..db69677 100644 --- a/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/PermissionApplyStatusViewModel.swift @@ -21,7 +21,7 @@ final class PermissionApplyStatusViewModel { init(applyCode: String, appStore: AppStore = .shared) { self.applyCode = applyCode self.appStore = appStore - rolePermissionList = appStore.rolePermissionList() + rolePermissionList = appStore.permissions.rolePermissionList() } func loadPendingData(api: HomeAPI) async { diff --git a/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift b/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift index 14e9ad2..6849be7 100644 --- a/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift @@ -45,7 +45,7 @@ final class PermissionApplyViewModel { } func loadRolesFromLocal() { - let rolePermissionList = appStore.rolePermissionList() + let rolePermissionList = appStore.permissions.rolePermissionList() var seen = Set() availableRoles = rolePermissionList.compactMap { item in guard !seen.contains(item.role.id) else { return nil } @@ -158,7 +158,7 @@ final class PermissionApplyViewModel { /// 已有角色下已开通的景区列表。 func existingScenics(for roleId: Int) -> [ScenicInfo] { - appStore.rolePermissionList() + appStore.permissions.rolePermissionList() .filter { $0.role.id == roleId } .flatMap(\.scenic) } diff --git a/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift b/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift index 9e152ba..1e9ba75 100644 --- a/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift @@ -43,7 +43,7 @@ final class ScenicSelectionViewModel { searchQuery: searchQuery, currentLat: currentLat, currentLng: currentLng, - selectedScenicId: appStore.currentScenicId + selectedScenicId: appStore.session.currentScenicId ) notifyStateChange() } @@ -73,8 +73,8 @@ final class ScenicSelectionViewModel { } func selectSpot(_ spot: ScenicSpotDisplayItem) { - appStore.currentScenicId = spot.id - appStore.currentScenicName = spot.name + appStore.session.currentScenicId = spot.id + appStore.session.currentScenicName = spot.name NotificationCenter.default.post( name: NotificationName.scenicDidChange, object: nil, @@ -109,11 +109,11 @@ final class ScenicSelectionViewModel { } private func resolvedScenicList() -> [ScenicInfo] { - let cached = appStore.roleScenicList() + let cached = appStore.permissions.roleScenicList() if !cached.isEmpty { return cached } - let rolePermissionList = appStore.rolePermissionList() + let rolePermissionList = appStore.permissions.rolePermissionList() guard !rolePermissionList.isEmpty else { return [] } - return appStore.matchRolePermissionItem(in: rolePermissionList)?.scenic ?? [] + return appStore.permissions.matchRolePermissionItem(in: rolePermissionList)?.scenic ?? [] } private func notifyStateChange() { diff --git a/suixinkan/Features/Live/ViewModels/LiveViewModels.swift b/suixinkan/Features/Live/ViewModels/LiveViewModels.swift index 211b40d..062c328 100644 --- a/suixinkan/Features/Live/ViewModels/LiveViewModels.swift +++ b/suixinkan/Features/Live/ViewModels/LiveViewModels.swift @@ -34,7 +34,7 @@ final class LiveManageViewModel { private let pageSize = 10 /// 初始化直播管理 ViewModel。 - init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { + init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) { self.currentScenicIdProvider = currentScenicIdProvider } @@ -282,7 +282,7 @@ final class LiveAlbumListViewModel { private let pageSize = 10 /// 初始化直播相册列表 ViewModel。 - init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { + init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) { self.currentScenicIdProvider = currentScenicIdProvider } @@ -419,7 +419,7 @@ final class LiveAlbumAddViewModel { private let currentScenicIdProvider: () -> Int /// 初始化直播相册上传 ViewModel。 - init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { + init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) { self.currentScenicIdProvider = currentScenicIdProvider } diff --git a/suixinkan/Features/Location/AMapBootstrap.swift b/suixinkan/Features/Location/AMapBootstrap.swift index f704995..2fee0b4 100644 --- a/suixinkan/Features/Location/AMapBootstrap.swift +++ b/suixinkan/Features/Location/AMapBootstrap.swift @@ -14,7 +14,7 @@ enum AMapBootstrap { /// 在用户已同意 App 隐私协议后配置高德 SDK,幂等。 @discardableResult static func configureIfNeeded(appStore: AppStore = .shared) -> Bool { - guard appStore.privacyAgreementAccepted else { return false } + guard appStore.session.privacyAgreementAccepted else { return false } lock.lock() defer { lock.unlock() } diff --git a/suixinkan/Features/LocationReport/Services/LocationReportService.swift b/suixinkan/Features/LocationReport/Services/LocationReportService.swift index 7974114..4ee05ba 100644 --- a/suixinkan/Features/LocationReport/Services/LocationReportService.swift +++ b/suixinkan/Features/LocationReport/Services/LocationReportService.swift @@ -44,7 +44,7 @@ final class LocationReportService { let remaining = Int((Self.operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000) throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining) } - guard appStore.currentScenicId > 0 else { + guard appStore.session.currentScenicId > 0 else { throw LocationReportServiceError.missingScenic } @@ -112,10 +112,10 @@ final class LocationReportService { } } - guard appStore.currentScenicId > 0 else { + guard appStore.session.currentScenicId > 0 else { throw LocationReportServiceError.missingScenic } - let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines) + let staffId = appStore.session.userId.trimmingCharacters(in: .whitespacesAndNewlines) guard !staffId.isEmpty else { throw LocationReportServiceError.missingStaffId } @@ -153,7 +153,7 @@ final class LocationReportService { longitude: snapshot.longitude, address: snapshot.address, type: type, - scenicId: String(appStore.currentScenicId) + scenicId: String(appStore.session.currentScenicId) ) lastLocationReportTime = currentTimestampMillis() diff --git a/suixinkan/Features/LocationReport/ViewModels/LocationReportHistoryViewModel.swift b/suixinkan/Features/LocationReport/ViewModels/LocationReportHistoryViewModel.swift index 2f59867..bed9f39 100644 --- a/suixinkan/Features/LocationReport/ViewModels/LocationReportHistoryViewModel.swift +++ b/suixinkan/Features/LocationReport/ViewModels/LocationReportHistoryViewModel.swift @@ -34,7 +34,7 @@ final class LocationReportHistoryViewModel { /// 加载历史上报列表。 func loadReportList(api: HomeAPI, refresh: Bool = false) async { guard !isLoading || refresh else { return } - let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines) + let staffId = appStore.session.userId.trimmingCharacters(in: .whitespacesAndNewlines) guard !staffId.isEmpty else { onShowMessage?("获取用户ID失败") return diff --git a/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift b/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift index 1154007..4d2d122 100644 --- a/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift +++ b/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift @@ -62,13 +62,6 @@ enum MaterialMediaType: Int, CaseIterable, Sendable, Equatable { } } - /// 云盘文件类型,视频为 1、图片为 2。 - var cloudFileType: Int { - switch self { - case .image: 2 - case .video: 1 - } - } } /// 素材列表项,对齐 Android `MaterialItemEntity`。 diff --git a/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift b/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift index 13d4caf..c2262bd 100644 --- a/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift +++ b/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift @@ -141,7 +141,12 @@ final class MaterialListViewModel { ) totalCount = response.total orderInfo = response.order ?? MaterialOrderInfo() - items = reset ? response.list : items + response.list + if reset { + items = response.list + } else { + let existingIDs = Set(items.map(\.id)) + items.append(contentsOf: response.list.filter { !existingIDs.contains($0.id) }) + } canLoadMore = items.count < totalCount } catch is CancellationError { return @@ -244,6 +249,7 @@ final class MaterialFormViewModel { private(set) var coverImage: MaterialUploadedMediaItem? private(set) var tagList: [MaterialTagItem] = [] private(set) var tagInput = "" + private(set) var pendingDeleteTag: MaterialTagItem? private(set) var uploadDialogState: MaterialUploadDialogState? private(set) var isSubmitting = false private(set) var isLoadingDetail = false @@ -259,7 +265,7 @@ final class MaterialFormViewModel { /// 初始化素材表单 ViewModel。 init( mode: Mode = .create, - currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId } + currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId } ) { self.mode = mode self.currentScenicIdProvider = currentScenicIdProvider @@ -295,7 +301,7 @@ final class MaterialFormViewModel { /// 更新标签输入。 func updateTagInput(_ text: String) { - tagInput = text + tagInput = String(text.prefix(15)) notifyStateChange() } @@ -327,9 +333,23 @@ final class MaterialFormViewModel { } } - /// 删除本地标签。 - func deleteTag(_ tag: MaterialTagItem) { + /// 请求删除标签,等待用户确认。 + func requestDeleteTag(_ tag: MaterialTagItem) { + pendingDeleteTag = tag + notifyStateChange() + } + + /// 取消删除标签。 + func cancelDeleteTag() { + pendingDeleteTag = nil + notifyStateChange() + } + + /// 确认删除当前待删除标签。 + func confirmDeleteTag() { + guard let tag = pendingDeleteTag else { return } tagList.removeAll { $0.id == tag.id } + pendingDeleteTag = nil notifyStateChange() } @@ -354,48 +374,6 @@ final class MaterialFormViewModel { notifyStateChange() } - /// 增加云盘素材文件,文件已在云盘中时直接使用其 URL。 - func addCloudMediaFiles(_ files: [CloudFile]) { - guard !files.isEmpty else { return } - let remaining = mediaType.maxCount - uploadedMediaList.count - guard remaining > 0 else { - onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张") - return - } - - var seenCloudIDs = importedCloudFileIDs - var accepted: [MaterialUploadedMediaItem] = [] - for file in files where accepted.count < remaining { - let fileURL = file.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) - guard file.type == mediaType.cloudFileType, !fileURL.isEmpty, !seenCloudIDs.contains(file.id) else { - continue - } - seenCloudIDs.insert(file.id) - accepted.append( - MaterialUploadedMediaItem( - id: "cloud_\(file.id)", - data: Data(), - thumbnailData: nil, - previewURL: file.coverUrl.isEmpty ? fileURL : file.coverUrl, - fileName: file.name.isEmpty ? "cloud_file_\(file.id)" : file.name, - size: file.fileSize, - width: 0, - height: 0, - ossUrl: fileURL, - isUploading: false, - uploadProgress: 100 - ) - ) - } - - guard !accepted.isEmpty else { - onShowMessage?(mediaType == .video ? "请选择视频素材" : "请选择图片素材") - return - } - uploadedMediaList.append(contentsOf: accepted) - notifyStateChange() - } - /// 设置封面并立即上传。 func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async { coverImage = item @@ -418,16 +396,6 @@ final class MaterialFormViewModel { notifyStateChange() } - /// 已从云盘导入的文件 ID。 - var importedCloudFileIDs: Set { - Set( - uploadedMediaList.compactMap { item in - guard item.id.hasPrefix("cloud_") else { return nil } - return Int(item.id.dropFirst("cloud_".count)) - } - ) - } - /// 上传或编辑素材。 func submit(api: any MaterialManagementServing) async { guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { diff --git a/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift b/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift index 2e2af92..326bcfc 100644 --- a/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift +++ b/suixinkan/Features/Orders/Utils/MainTabScanHandler.swift @@ -40,7 +40,7 @@ final class MainTabScanHandler { case "check_in": await performCheckIn(scanResult: scanResult) case "order_verify": - if AppStore.shared.currentAppRole == .storeAdmin { + if AppStore.shared.session.currentAppRole == .storeAdmin { await performStoreVerify(parsed: parsed) } else { await performPhotographerVerify(jsonBody: parsed.rawJSON) @@ -92,7 +92,7 @@ final class MainTabScanHandler { onShowMessage?("请扫描正确的二维码") return } - json["scenic_id"] = String(AppStore.shared.currentScenicId) + json["scenic_id"] = String(AppStore.shared.session.currentScenicId) guard let body = serializeJSON(json) else { return } do { let response = try await api.orderCheckIn(jsonBody: body) @@ -116,7 +116,7 @@ final class MainTabScanHandler { onShowMessage?("请扫描正确的核销码") return } - let storeId = AppStore.shared.currentStoreId + let storeId = AppStore.shared.session.currentStoreId guard storeId > 0 else { onShowMessage?("店铺信息异常,请先选择店铺") return diff --git a/suixinkan/Features/Orders/ViewModels/DepositOrderListViewModel.swift b/suixinkan/Features/Orders/ViewModels/DepositOrderListViewModel.swift index e866c37..fbd0c82 100644 --- a/suixinkan/Features/Orders/ViewModels/DepositOrderListViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/DepositOrderListViewModel.swift @@ -102,7 +102,7 @@ final class DepositOrderListViewModel { onShowMessage?("核销码内容不完整") return } - let storeId = AppStore.shared.currentStoreId + let storeId = AppStore.shared.session.currentStoreId guard storeId > 0 else { onShowMessage?("店铺信息异常,请先选择店铺") return @@ -125,7 +125,7 @@ final class DepositOrderListViewModel { refundReason: String, api: OrderAPI ) async { - let storeId = AppStore.shared.currentStoreId + let storeId = AppStore.shared.session.currentStoreId guard storeId > 0 else { onShowMessage?("店铺信息异常") return @@ -166,7 +166,7 @@ final class DepositOrderListViewModel { let phone = searchText.trimmingCharacters(in: .whitespacesAndNewlines) let isPhoneSearch = !phone.isEmpty && Self.isValidPhone(phone) - let storeId = AppStore.shared.currentStoreId + let storeId = AppStore.shared.session.currentStoreId if storeId <= 0 { finishLoading() orderList = [] diff --git a/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift index 6d3a0ac..bcc8815 100644 --- a/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift @@ -122,7 +122,7 @@ final class OrderListViewModel { }() var isScenicAdminReadOnly: Bool { - AppStore.shared.currentAppRole == .scenicAdmin + AppStore.shared.session.currentAppRole == .scenicAdmin } var listAPIPath: String { @@ -359,7 +359,7 @@ final class OrderListViewModel { isLoadingOrderSourcePeople = false notifyStateChange() } - let storeId = AppStore.shared.currentStoreId + let storeId = AppStore.shared.session.currentStoreId let storeIdParam = storeId > 0 ? String(storeId) : nil do { let response = try await api.cooperativeSalers(storeId: storeIdParam) @@ -402,7 +402,7 @@ final class OrderListViewModel { } private func loadOrderList(api: OrderAPI, append: Bool) async { - let scenicId = AppStore.shared.currentScenicId + let scenicId = AppStore.shared.session.currentScenicId guard scenicId > 0 else { finishLoading() onShowMessage?("当前景区信息缺失") @@ -479,7 +479,7 @@ final class OrderListViewModel { var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - json["scenic_id"] = String(AppStore.shared.currentScenicId) + json["scenic_id"] = String(AppStore.shared.session.currentScenicId) guard let updated = try? JSONSerialization.data(withJSONObject: json), let string = String(data: updated, encoding: .utf8) else { return nil diff --git a/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift index 6873b8d..8b22f38 100644 --- a/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift +++ b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift @@ -40,9 +40,9 @@ final class PaymentCollectionDetailsViewModel { } func refreshLocalState() { - scenicName = appStore.currentScenicName - staffId = appStore.userId - isVoiceBroadcastOpen = appStore.isOpenReceiveVoice + scenicName = appStore.session.currentScenicName + staffId = appStore.session.userId + isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice notifyStateChange() } @@ -56,7 +56,7 @@ final class PaymentCollectionDetailsViewModel { notifyStateChange() } - let scenicId = appStore.currentScenicId + let scenicId = appStore.session.currentScenicId guard scenicId > 0 else { clearPayCode() return @@ -134,7 +134,7 @@ final class PaymentCollectionDetailsViewModel { func toggleReceiveVoice() { isVoiceBroadcastOpen.toggle() - appStore.isOpenReceiveVoice = isVoiceBroadcastOpen + appStore.payment.isOpenReceiveVoice = isVoiceBroadcastOpen notifyStateChange() } diff --git a/suixinkan/Features/Payment/ViewModels/PaymentCollectionRecordViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentCollectionRecordViewModel.swift index cd300ff..9b86584 100644 --- a/suixinkan/Features/Payment/ViewModels/PaymentCollectionRecordViewModel.swift +++ b/suixinkan/Features/Payment/ViewModels/PaymentCollectionRecordViewModel.swift @@ -28,7 +28,7 @@ final class PaymentCollectionRecordViewModel { notifyStateChange() } - let scenicId = appStore.currentScenicId + let scenicId = appStore.session.currentScenicId guard scenicId > 0 else { groups = [] return diff --git a/suixinkan/Features/Profile/API/ProfileAPI.swift b/suixinkan/Features/Profile/API/ProfileAPI.swift index f6ff9bc..7bc9a5a 100644 --- a/suixinkan/Features/Profile/API/ProfileAPI.swift +++ b/suixinkan/Features/Profile/API/ProfileAPI.swift @@ -65,6 +65,17 @@ final class ProfileAPI { ) } + /// 上传摄影师头像,对齐 Android multipart 头像更新接口。 + func uploadAvatar(data: Data, fileName: String) async throws { + let _: EmptyPayload = try await client.sendMultipart( + path: "/api/yf-handset-app/photog/userinfo-update-avatar", + fileName: fileName, + mimeType: "image/jpeg", + data: data, + timeoutInterval: 60 + ) + } + /// 拉取当前登录账号可切换的景区账号和门店账号。 func switchableAccounts() async throws -> V9AuthResponse { try await client.send( diff --git a/suixinkan/Features/Profile/ViewModels/ProfileEditViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileEditViewModel.swift index 996ee7d..1b624bc 100644 --- a/suixinkan/Features/Profile/ViewModels/ProfileEditViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/ProfileEditViewModel.swift @@ -72,9 +72,9 @@ final class ProfileEditViewModel { try await api.updateUserInfo(nickname: nextNickname) } - AppStore.shared.userName = nextNickname + AppStore.shared.session.userName = nextNickname if let uploadedAvatarURL { - AppStore.shared.avatar = uploadedAvatarURL + AppStore.shared.session.avatar = uploadedAvatarURL } clearPendingAvatar() diff --git a/suixinkan/Features/Profile/ViewModels/ProfileSpaceSettingsViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileSpaceSettingsViewModel.swift index 6536671..5877f5d 100644 --- a/suixinkan/Features/Profile/ViewModels/ProfileSpaceSettingsViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/ProfileSpaceSettingsViewModel.swift @@ -22,6 +22,9 @@ protocol ProfileSpaceSettingsAPI { /// 删除个人日程。 func deleteSchedule(id: Int) async throws + + /// 上传裁剪后的摄影师头像。 + func uploadAvatar(data: Data, fileName: String) async throws } extension ProfileAPI: ProfileSpaceSettingsAPI {} @@ -85,6 +88,7 @@ final class ProfileSpaceSettingsViewModel { private(set) var scheduleMap: [String: [PhotographerSchedule]] = [:] private(set) var selectedDate: Date private(set) var isEditingProfile = false + private(set) var isCertificationExpanded = false private(set) var isLoading = false private(set) var hasChanges = false @@ -109,7 +113,7 @@ final class ProfileSpaceSettingsViewModel { /// 当前景区 ID。 var scenicId: Int { - appStore.currentScenicId + appStore.session.currentScenicId } /// 选中日期对应日程。 @@ -156,7 +160,7 @@ final class ProfileSpaceSettingsViewModel { do { try await api.updateUserInfo(nickname: newNickname, password: nil, avatar: nil) nickname = newNickname - appStore.userName = newNickname + appStore.session.userName = newNickname isEditingProfile = false onShowMessage?("修改成功") notifyStateChange() @@ -175,10 +179,35 @@ final class ProfileSpaceSettingsViewModel { /// 更新头像 URL。 func updateAvatarURL(_ url: String) { avatarURL = url - appStore.avatar = url + appStore.session.avatar = url notifyStateChange() } + /// 展开或收起景区认证标签。 + func toggleCertificationExpanded() { + isCertificationExpanded.toggle() + notifyStateChange() + } + + /// 上传头像并重新拉取个人空间配置。 + func uploadAvatar( + data: Data, + fileName: String, + api: any ProfileSpaceSettingsAPI + ) async { + setLoading(true) + defer { setLoading(false) } + do { + try await api.uploadAvatar(data: data, fileName: fileName) + onShowMessage?("上传成功") + await load(api: api) + } catch is CancellationError { + return + } catch { + onShowMessage?(error.localizedDescription) + } + } + /// 更新编辑昵称。 func updateEditingNickname(_ value: String) { editingNickname = value @@ -225,41 +254,41 @@ final class ProfileSpaceSettingsViewModel { /// 更新工作日开始时间。 func updateBusinessStartTime(_ value: String) { - businessStartTime = value if let end = businessEndTime, !Self.isStart(value, before: end) { onShowMessage?("开始时间不能大于结束时间") - businessStartTime = nil + return } + businessStartTime = value updateHasChanges() } /// 更新工作日结束时间。 func updateBusinessEndTime(_ value: String) { - businessEndTime = value if let start = businessStartTime, !Self.isStart(start, before: value) { onShowMessage?("结束时间不能小于开始时间") - businessEndTime = nil + return } + businessEndTime = value updateHasChanges() } /// 更新节假日开始时间。 func updateHolidayStartTime(_ value: String) { - holidayStartTime = value if let end = holidayEndTime, !Self.isStart(value, before: end) { onShowMessage?("开始时间不能大于结束时间") - holidayStartTime = nil + return } + holidayStartTime = value updateHasChanges() } /// 更新节假日结束时间。 func updateHolidayEndTime(_ value: String) { - holidayEndTime = value if let start = holidayStartTime, !Self.isStart(start, before: value) { onShowMessage?("结束时间不能小于开始时间") - holidayEndTime = nil + return } + holidayEndTime = value updateHasChanges() } @@ -417,7 +446,7 @@ final class ProfileSpaceSettingsViewModel { editingNickname = response.nickname if !response.avatar.isEmpty { avatarURL = response.avatar - appStore.avatar = response.avatar + appStore.session.avatar = response.avatar } scenicCertification = response.scenicCertification introduction = response.description @@ -430,8 +459,8 @@ final class ProfileSpaceSettingsViewModel { holidayEndTime = Self.shortTimeString(response.holidayEndTime) acceptOrderStatus = response.acceptOrderStatus scheduleMap = response.schedule - appStore.userName = response.nickname - appStore.realName = response.realName + appStore.session.userName = response.nickname + appStore.session.realName = response.realName originalSnapshot = currentSnapshot() hasChanges = false notifyStateChange() diff --git a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift index b172e62..b44fcac 100644 --- a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift @@ -25,42 +25,42 @@ final class ProfileViewModel { } var displayPhone: String { - nonEmpty(userInfo?.phone) ?? AppStore.shared.phone.nonEmpty ?? "--" + nonEmpty(userInfo?.phone) ?? AppStore.shared.session.phone.nonEmpty ?? "--" } var displayAvatarURL: String { - nonEmpty(userInfo?.avatar) ?? AppStore.shared.avatar.nonEmpty ?? "" + nonEmpty(userInfo?.avatar) ?? AppStore.shared.session.avatar.nonEmpty ?? "" } var displayUID: String { - let uid = AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines) + let uid = AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines) return uid.isEmpty ? "--" : uid } var accountDisplayName: String { - let name = AppStore.shared.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) + let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? "--" : name } /// 当前账号类型文案,如「门店账号」「景区账号」。 var accountTypeLabel: String { - let type = AppStore.shared.accountType.trimmingCharacters(in: .whitespacesAndNewlines) - if type == V9StoreUser.accountTypeValue { - return "门店账号" + switch AppStore.shared.session.accountType { + case .storeUser: + "门店账号" + case .scenicUser: + "景区账号" + case .unknown: + "--" } - if type == V9ScenicUser.accountTypeValue { - return "景区账号" - } - return "--" } /// 当前是否为门店账号。 var isStoreAccount: Bool { - AppStore.shared.accountType == V9StoreUser.accountTypeValue + AppStore.shared.session.accountType == .storeUser } var currentScenicName: String { - let name = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) + let name = AppStore.shared.session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) return name.isEmpty ? "--" : name } @@ -69,7 +69,7 @@ final class ProfileViewModel { } var showPhotographerFields: Bool { - if AppStore.shared.isPhotographerRole { + if AppStore.shared.session.isPhotographerRole { return true } if let roleName = nonEmpty(userInfo?.roleName) { @@ -108,9 +108,9 @@ final class ProfileViewModel { let info = try await api.userInfo() userInfo = info - AppStore.shared.applyUserInfo(info) + AppStore.shared.session.applyUserInfo(info) if !info.roleName.isEmpty { - AppStore.shared.roleName = info.roleName + AppStore.shared.session.roleName = info.roleName } if showPhotographerFields { @@ -128,8 +128,8 @@ final class ProfileViewModel { /// 从 `AppStore` 同步头像与昵称,用于资料保存后的本地即时刷新。 func applyLocalProfileUpdate(from store: AppStore = .shared) { let previous = userInfo ?? UserInfoResponse() - let nextNickname = store.userName.trimmingCharacters(in: .whitespacesAndNewlines) - let nextAvatar = store.avatar.trimmingCharacters(in: .whitespacesAndNewlines) + let nextNickname = store.session.userName.trimmingCharacters(in: .whitespacesAndNewlines) + let nextAvatar = store.session.avatar.trimmingCharacters(in: .whitespacesAndNewlines) userInfo = UserInfoResponse( avatar: nextAvatar.isEmpty ? previous.avatar : nextAvatar, realName: previous.realName, diff --git a/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift index 2ac14a4..6675b09 100644 --- a/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift +++ b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift @@ -36,7 +36,7 @@ final class PunchPointListViewModel { private let currentScenicIdProvider: () -> Int /// 初始化打卡点列表 ViewModel。 - init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }) { + init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) { self.currentScenicIdProvider = currentScenicIdProvider } @@ -223,7 +223,7 @@ final class PunchPointFormViewModel { /// 初始化打卡点表单 ViewModel。 init( mode: Mode = .create, - currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }, + currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, locationProvider: any LocationProviding = LocationProvider.shared ) { self.mode = mode diff --git a/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift b/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift index 035045f..3d366bc 100644 --- a/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift +++ b/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift @@ -222,8 +222,8 @@ final class UploadSampleViewModel { /// 初始化样片上传 ViewModel。 init( - scenicListProvider: @escaping () -> [ScenicInfo] = { AppStore.shared.roleScenicList() }, - currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId } + scenicListProvider: @escaping () -> [ScenicInfo] = { AppStore.shared.permissions.roleScenicList() }, + currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId } ) { self.scenicListProvider = scenicListProvider self.currentScenicIdProvider = currentScenicIdProvider diff --git a/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift b/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift index 909169f..a9bbd9a 100644 --- a/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift +++ b/suixinkan/Features/ScenicQueue/Services/ScenicQueueTTSManager.swift @@ -84,7 +84,7 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async { guard let api else { return } - guard !appStore.scenicQueueUseOfflineTTS || supportsOfflineTTS else { + guard !appStore.scenicQueue.useOfflineTTS || supportsOfflineTTS else { nativeReady = false return } diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift index 6ab9e05..601b91f 100644 --- a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingChangeLogViewModel.swift @@ -39,7 +39,7 @@ final class ScenicQueueSettingChangeLogViewModel { } private func load(api: ScenicQueueAPIProtocol, page: Int, append: Bool) async { - guard appStore.currentScenicId > 0 else { + guard appStore.session.currentScenicId > 0 else { onShowMessage?("请先选择景区") return } @@ -53,10 +53,10 @@ final class ScenicQueueSettingChangeLogViewModel { if append { isLoadingMore = false } else { isRefreshing = false } notifyStateChange() } - let spotId = Int64(appStore.scenicQueuePunchSpotId) + let spotId = Int64(appStore.scenicQueue.punchSpotId) do { let data = try await api.settingChangeLog( - scenicId: Int64(appStore.currentScenicId), + scenicId: Int64(appStore.session.currentScenicId), scenicSpotId: spotId, page: page, pageSize: Constants.pageSize diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift index 0c0661f..e256059 100644 --- a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift @@ -61,9 +61,9 @@ final class ScenicQueueSettingsViewModel { init(appStore: AppStore = .shared, ttsManager: ScenicQueueTTSManaging = AliyunNLSTTSManager.shared) { self.appStore = appStore self.ttsManager = ttsManager - let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() - selectedPunchSpotId = appStore.scenicQueuePunchSpotId.nonEmptyTrimmed - selectedPunchSpotLabel = appStore.scenicQueuePunchSpotName + let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot() + selectedPunchSpotId = appStore.scenicQueue.punchSpotId.nonEmptyTrimmed + selectedPunchSpotLabel = appStore.scenicQueue.punchSpotName shootMinute = snapshot.shootMinute.clamped(to: Limits.shootMinute) shootSecond = snapshot.shootSecond.clamped(to: Limits.shootSecond) firstAheadCount = snapshot.firstAheadCount.clamped(to: Limits.ahead) @@ -84,8 +84,8 @@ final class ScenicQueueSettingsViewModel { businessOpen = snapshot.businessOpen businessStartTime = Self.normalizedTime(snapshot.businessStartTime, fallback: "10:00") businessEndTime = Self.normalizedTime(snapshot.businessEndTime, fallback: "20:00") - customTTSText = appStore.scenicQueueRemark - presetVoices = appStore.scenicQueuePresetVoices() + customTTSText = appStore.scenicQueue.remark + presetVoices = appStore.scenicQueue.presetVoices() } var selectedPunchSpotDisplayText: String { @@ -105,7 +105,7 @@ final class ScenicQueueSettingsViewModel { /// 是否优先使用离线语音合成。 var useOfflineTTS: Bool { - appStore.scenicQueueUseOfflineTTS + appStore.scenicQueue.useOfflineTTS } func openSettingChangeLog() { @@ -114,12 +114,12 @@ final class ScenicQueueSettingsViewModel { /// 打开打卡点选择器前加载全部打卡点。 func loadPunchSpotOptions(api: ScenicQueueAPIProtocol) async { - guard appStore.currentScenicId > 0 else { + guard appStore.session.currentScenicId > 0 else { onShowMessage?("请先选择景区") return } do { - let response = try await api.scenicSpotListAll(scenicId: String(appStore.currentScenicId)) + let response = try await api.scenicSpotListAll(scenicId: String(appStore.session.currentScenicId)) punchSpotOptions = response.list.map { ScenicQueuePunchSpotOption(id: String($0.id), label: $0.name) } @@ -178,17 +178,17 @@ final class ScenicQueueSettingsViewModel { /// 设置离线语音合成偏好。 func setUseOfflineTTS(_ enabled: Bool) { guard enabled else { - appStore.scenicQueueUseOfflineTTS = false + appStore.scenicQueue.useOfflineTTS = false notifyStateChange() return } guard ttsManager.supportsOfflineTTS else { - appStore.scenicQueueUseOfflineTTS = false + appStore.scenicQueue.useOfflineTTS = false onShowMessage?("离线语音资源未安装") notifyStateChange() return } - appStore.scenicQueueUseOfflineTTS = true + appStore.scenicQueue.useOfflineTTS = true notifyStateChange() } @@ -234,7 +234,7 @@ final class ScenicQueueSettingsViewModel { /// 保存当前文案到本地预设。 func saveCurrentTextAsPresetVoiceLocal() { - guard appStore.currentScenicId > 0, selectedPunchSpotId?.nonEmptyTrimmed != nil else { + guard appStore.session.currentScenicId > 0, selectedPunchSpotId?.nonEmptyTrimmed != nil else { onShowMessage?("请先选择打卡点") return } @@ -252,7 +252,7 @@ final class ScenicQueueSettingsViewModel { return } presetVoices.append(text) - appStore.saveScenicQueuePresetVoices(presetVoices) + appStore.scenicQueue.savePresetVoices(presetVoices) onShowMessage?("已保存到本地") notifyStateChange() } @@ -260,7 +260,7 @@ final class ScenicQueueSettingsViewModel { func deletePresetVoice(at index: Int) { guard presetVoices.indices.contains(index) else { return } let removed = presetVoices.remove(at: index) - appStore.saveScenicQueuePresetVoices(presetVoices) + appStore.scenicQueue.savePresetVoices(presetVoices) if ttsLoopAnchorText == removed { ttsManager.stopCustomTextLoop() ttsLoopAnchorText = nil @@ -332,11 +332,11 @@ final class ScenicQueueSettingsViewModel { ) do { try await api.saveSetting(request) - appStore.scenicQueuePunchSpotId = String(ids.scenicSpotId) - appStore.scenicQueuePunchSpotName = selectedPunchSpotLabel - appStore.scenicQueueRemark = remark - appStore.saveScenicQueuePresetVoices(presetVoices) - appStore.saveScenicQueueSettingsSnapshot(currentSnapshot()) + appStore.scenicQueue.punchSpotId = String(ids.scenicSpotId) + appStore.scenicQueue.punchSpotName = selectedPunchSpotLabel + appStore.scenicQueue.remark = remark + appStore.scenicQueue.savePresetVoices(presetVoices) + appStore.scenicQueue.saveSettingsSnapshot(currentSnapshot()) _ = try? await api.stats(scenicId: ids.scenicId, scenicSpotId: ids.scenicSpotId) onShowMessage?("保存成功") onNavigateBack?() @@ -346,7 +346,7 @@ final class ScenicQueueSettingsViewModel { } private func fetchQueueSettingAfterPunchSpotSelected(_ spotId: String, api: ScenicQueueAPIProtocol) async { - guard let scenicId = Int64("\(appStore.currentScenicId)"), + guard let scenicId = Int64("\(appStore.session.currentScenicId)"), let scenicSpotId = Int64(spotId), scenicId > 0, scenicSpotId > 0 else { return } @@ -480,7 +480,7 @@ final class ScenicQueueSettingsViewModel { } private func currentScenicAndSpotIdsForSelected() -> (scenicId: Int64, scenicSpotId: Int64)? { - guard let scenicId = Int64("\(appStore.currentScenicId)"), scenicId > 0, + guard let scenicId = Int64("\(appStore.session.currentScenicId)"), scenicId > 0, let spotIdText = selectedPunchSpotId?.trimmingCharacters(in: .whitespacesAndNewlines), let scenicSpotId = Int64(spotIdText), scenicSpotId > 0 else { return nil } return (scenicId, scenicSpotId) diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift index 448751d..bc53a32 100644 --- a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift @@ -97,8 +97,8 @@ final class ScenicQueueViewModel { /// 开始 WebSocket 订阅。 func startQueueStatsPolling(api: ScenicQueueAPIProtocol) { guard queueGatePassed, - let scenicId = Int64("\(appStore.currentScenicId)"), - let scenicSpotId = Int64(appStore.scenicQueuePunchSpotId), + let scenicId = Int64("\(appStore.session.currentScenicId)"), + let scenicSpotId = Int64(appStore.scenicQueue.punchSpotId), scenicId > 0, scenicSpotId > 0 else { return } Task { @@ -186,16 +186,16 @@ final class ScenicQueueViewModel { /// 确认用户标记。 func confirmUserMark(api: ScenicQueueAPIProtocol, markAsFreelancePhotog: Bool, queueBanDays: Int) async { guard let pendingMark else { return } - guard appStore.currentScenicId > 0 else { + guard appStore.session.currentScenicId > 0 else { onShowMessage?("请先选择景区") return } let request = ScenicQueueUserMarkRequest( uid: pendingMark.uid, - scenicId: Int64(appStore.currentScenicId), + scenicId: Int64(appStore.session.currentScenicId), markAsFreelancePhotog: markAsFreelancePhotog ? 1 : 0, queueBanDays: markAsFreelancePhotog ? min(max(queueBanDays, 0), 999) : nil, - operatorId: Int(appStore.userId) + operatorId: Int(appStore.session.userId) ) do { try await api.userMark(request) @@ -270,7 +270,7 @@ final class ScenicQueueViewModel { func confirmRequeueTicket(api: ScenicQueueAPIProtocol) async { guard let pending = pendingRequeue else { return } dismissRequeueDialog() - guard let operatorId = Int(appStore.userId), operatorId > 0 else { + guard let operatorId = Int(appStore.session.userId), operatorId > 0 else { onShowMessage?("请先登录") return } @@ -395,18 +395,18 @@ final class ScenicQueueViewModel { } private func refreshSelectedPunchSpotLine() { - selectedPunchSpotLine = appStore.hasScenicQueuePunchSpotSaved() - ? appStore.scenicQueuePunchSpotName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "--" + selectedPunchSpotLine = appStore.scenicQueue.hasPunchSpotSaved() + ? appStore.scenicQueue.punchSpotName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "--" : "--" refreshShootingCallConfig() } private func refreshQueueGateLocally() { - queueGatePassed = appStore.hasScenicQueuePunchSpotSaved() + queueGatePassed = appStore.scenicQueue.hasPunchSpotSaved() } private func refreshShootingCallConfig() { - let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() + let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot() showStartShootingButton = snapshot.showStartShootingButton quickCallButtonEnabled = snapshot.quickCallButtonEnabled prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled @@ -419,18 +419,18 @@ final class ScenicQueueViewModel { guard data.exists, let setting = data.setting, setting.scenicId == nil || setting.scenicId == ids.scenicId, setting.scenicSpotId == nil || setting.scenicSpotId == ids.scenicSpotId else { return } - let local = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() - appStore.saveScenicQueueSettingsSnapshot(setting.toSnapshot(fallback: local)) - appStore.scenicQueueRemark = setting.remark ?? "" + let local = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot() + appStore.scenicQueue.saveSettingsSnapshot(setting.toSnapshot(fallback: local)) + appStore.scenicQueue.remark = setting.remark ?? "" if let voices = setting.voiceBroadcasts { - appStore.saveScenicQueuePresetVoices( + appStore.scenicQueue.savePresetVoices( voices .sorted { ($0.sortOrder ?? Int.max) < ($1.sortOrder ?? Int.max) } .compactMap { $0.content?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty } ) } if let name = setting.scenicSpotName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty { - appStore.scenicQueuePunchSpotName = name + appStore.scenicQueue.punchSpotName = name selectedPunchSpotLine = name } refreshShootingCallConfig() @@ -501,7 +501,7 @@ final class ScenicQueueViewModel { private func handleSocketMessage(_ message: ScenicQueueSocketMessage) { guard let params = message.data?.params, let changedSpotId = params.scenicSpotId, - let currentSpotId = Int64(appStore.scenicQueuePunchSpotId), + let currentSpotId = Int64(appStore.scenicQueue.punchSpotId), changedSpotId == currentSpotId else { return } switch message.data?.action { case ScenicQueueSocketAction.queueUpdated.rawValue: @@ -516,7 +516,7 @@ final class ScenicQueueViewModel { private func handleRemoteTicketCalled(_ params: ScenicQueueSocketParams) { guard let recordId = params.recordId, recordId > 0 else { return } - if let myUid = Int64(appStore.userId), let operatorUid = params.operatorUid, myUid == operatorUid { + if let myUid = Int64(appStore.session.userId), let operatorUid = params.operatorUid, myUid == operatorUid { return } let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -614,7 +614,7 @@ final class ScenicQueueViewModel { } private func resolveShootingTiming() -> (totalSeconds: Int, intervalSeconds: Int, readableThresholdSeconds: Int) { - let snapshot = appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot() + let snapshot = appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot() let total = max(0, snapshot.shootMinute * 60 + snapshot.shootSecond) let interval = (40...60).contains(snapshot.broadcastIntervalSec) ? snapshot.broadcastIntervalSec : 50 let readable = (10...30).contains(snapshot.countdownThresholdSec) ? snapshot.countdownThresholdSec : 15 @@ -627,7 +627,7 @@ final class ScenicQueueViewModel { } private func speakFollowingTickets(after queueNo: String) async { - let count = (appStore.scenicQueueSettingsSnapshot() ?? ScenicQueueSettingsSnapshot()).autoCallAheadCount.clamped(to: 0...5) + let count = (appStore.scenicQueue.settingsSnapshot() ?? ScenicQueueSettingsSnapshot()).autoCallAheadCount.clamped(to: 0...5) guard count > 0, let index = currentQueue.firstIndex(where: { $0.queueNo == queueNo }) else { return } let nextNos = currentQueue.dropFirst(index + 1).prefix(count).map(\.queueNo).filter { !$0.isEmpty } guard !nextNos.isEmpty else { return } @@ -639,9 +639,9 @@ final class ScenicQueueViewModel { } private func currentScenicAndSpotIds() -> (scenicId: Int64, scenicSpotId: Int64)? { - let scenicId = Int64(appStore.currentScenicId) + let scenicId = Int64(appStore.session.currentScenicId) guard scenicId > 0, - let scenicSpotId = Int64(appStore.scenicQueuePunchSpotId), + let scenicSpotId = Int64(appStore.scenicQueue.punchSpotId), scenicSpotId > 0 else { return nil } return (scenicId, scenicSpotId) } diff --git a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift index 897c554..8fb644d 100644 --- a/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift +++ b/suixinkan/Features/Statistics/ViewModels/StatisticsViewModel.swift @@ -213,9 +213,9 @@ final class StatisticsViewModel { let storeId = try resolveStoreId() return try await api.storeSummary(storeId: storeId, range: range) case .scenicAdmin: - return try await api.scenicAdminSummary(scenicId: AppStore.shared.currentScenicId, range: range) + return try await api.scenicAdminSummary(scenicId: AppStore.shared.session.currentScenicId, range: range) default: - return try await api.photographerSummary(scenicId: AppStore.shared.currentScenicId, range: range) + return try await api.photographerSummary(scenicId: AppStore.shared.session.currentScenicId, range: range) } } @@ -231,14 +231,14 @@ final class StatisticsViewModel { ) case .scenicAdmin: return try await api.scenicAdminDaily( - scenicId: AppStore.shared.currentScenicId, + scenicId: AppStore.shared.session.currentScenicId, startTime: startTime, endTime: endTime, page: currentPage ) default: return try await api.photographerDaily( - scenicId: AppStore.shared.currentScenicId, + scenicId: AppStore.shared.session.currentScenicId, startTime: startTime, endTime: endTime, page: currentPage @@ -247,11 +247,11 @@ final class StatisticsViewModel { } private var currentRole: AppRoleCode { - AppStore.shared.currentAppRole ?? .photographer + AppStore.shared.session.currentAppRole ?? .photographer } private func resolveStoreId() throws -> Int { - let storeId = AppStore.shared.currentStoreId + let storeId = AppStore.shared.session.currentStoreId guard storeId > 0 else { throw StatisticsFlowError.missingStore } diff --git a/suixinkan/Features/Task/ViewModels/TaskAddViewModel.swift b/suixinkan/Features/Task/ViewModels/TaskAddViewModel.swift index a3f015c..d41a756 100644 --- a/suixinkan/Features/Task/ViewModels/TaskAddViewModel.swift +++ b/suixinkan/Features/Task/ViewModels/TaskAddViewModel.swift @@ -262,7 +262,7 @@ final class TaskAddViewModel { ) } return AddTaskRequest( - scenicId: appStore.currentScenicId, + scenicId: appStore.session.currentScenicId, name: name ?? taskName, orderNumber: selectedOrderNumber, remark: taskDetails, diff --git a/suixinkan/Features/Task/ViewModels/TaskOrderSelectViewModel.swift b/suixinkan/Features/Task/ViewModels/TaskOrderSelectViewModel.swift index 7e35514..d4c80ae 100644 --- a/suixinkan/Features/Task/ViewModels/TaskOrderSelectViewModel.swift +++ b/suixinkan/Features/Task/ViewModels/TaskOrderSelectViewModel.swift @@ -32,7 +32,7 @@ final class TaskOrderSelectViewModel { notifyStateChange() } - let scenicId = appStore.currentScenicId + let scenicId = appStore.session.currentScenicId guard scenicId > 0 else { orders = [] onShowMessage?("请先选择景区") diff --git a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift index d971b2d..b043f54 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift @@ -151,10 +151,10 @@ struct TravelAlbumOTGStorageContext: Equatable, Sendable { /// 使用当前登录账号创建存储上下文。 static var current: TravelAlbumOTGStorageContext { TravelAlbumOTGStorageContext( - accountCachePrefix: AppStore.shared.accountCachePrefix, - userId: AppStore.shared.userId, - scenicId: AppStore.shared.currentScenicId, - storeId: AppStore.shared.currentStoreId + accountCachePrefix: AppStore.shared.session.accountCachePrefix, + userId: AppStore.shared.session.userId, + scenicId: AppStore.shared.session.currentScenicId, + storeId: AppStore.shared.session.currentStoreId ) } } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift index 5f00371..1913bce 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift @@ -201,7 +201,7 @@ final class TravelAlbumCameraImportViewModel { status: .pending, progress: 0, albumId: albumId, - userId: appStore.userId + userId: appStore.session.userId ) storage.upsert(record, albumId: albumId) existingPhotoIds.insert(object.id) diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift index de13303..dbb7c2c 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift @@ -44,7 +44,7 @@ final class TravelAlbumEntryViewModel { private let dateProvider: () -> Date init( - currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }, + currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, dateProvider: @escaping () -> Date = Date.init ) { self.currentScenicIdProvider = currentScenicIdProvider diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift index d5b04af..88615bd 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -383,7 +383,7 @@ final class WiredCameraTransferViewModel { fileSizeBytes: Int64(item.data.count), status: .pending, albumId: albumId, - userId: appStore.userId + userId: appStore.session.userId ) storage.upsert(record, albumId: albumId) importedPhotoIds.append(record.id) @@ -553,7 +553,7 @@ final class WiredCameraTransferViewModel { updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) let material = try await uploader.upload( record: record, - scenicId: appStore.currentScenicId + scenicId: appStore.session.currentScenicId ) { [weak self] progress in self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) } @@ -596,7 +596,7 @@ final class WiredCameraTransferViewModel { progress: progress, errorMessage: error, albumId: albumId, - userId: appStore.userId, + userId: appStore.session.userId, remoteUrl: remoteUrl ?? item.remoteUrl ) } diff --git a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift index c4a554d..b79e2b3 100644 --- a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift +++ b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift @@ -99,7 +99,7 @@ final class WalletViewModel { /// 初始化钱包首页 ViewModel。 init( staffIdProvider: @escaping () -> Int? = { - Int(AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)) + Int(AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)) }, calendar: Calendar = Calendar(identifier: .gregorian) ) { @@ -480,7 +480,7 @@ final class PointsRedemptionViewModel { /// 初始化积分兑现 ViewModel。 init(staffIdProvider: @escaping () -> Int? = { - Int(AppStore.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)) + Int(AppStore.shared.session.userId.trimmingCharacters(in: .whitespacesAndNewlines)) }) { self.staffIdProvider = staffIdProvider } diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index 7b108ca..9c51833 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -1057,7 +1057,7 @@ final class WildReportRiskMapViewModel { init() { self.markers = [] self.region = Self.region(for: []) - let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) + let scenicName = AppStore.shared.session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) self.scenicAreaName = scenicName.isEmpty ? "当前景区" : scenicName self.selectedMarkerID = nil } @@ -1203,7 +1203,7 @@ final class WildReportRiskMapViewModel { scenicName: String?, selectCurrentLocation: Bool ) async { - let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId + let resolvedScenicId = scenicId ?? AppStore.shared.session.currentScenicId guard resolvedScenicId >= 1 else { errorMessage = "请先选择景区后再查看风险地图" notifyStateChange() @@ -1244,7 +1244,7 @@ final class WildReportRiskMapViewModel { )) scenicAreaName = response.scenicName.nonEmpty ?? scenicName?.nonEmpty - ?? AppStore.shared.currentScenicName.nonEmpty + ?? AppStore.shared.session.currentScenicName.nonEmpty ?? scenicAreaName greenMarkerTip = response.greenMarkerTip legendItems = response.legend @@ -1376,7 +1376,7 @@ final class WildReportRiskMapViewModel { for marker: WildReportMapMarker, scenicId: Int? ) -> WildReportMarkerDetailRequest? { - let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId + let resolvedScenicId = scenicId ?? AppStore.shared.session.currentScenicId guard resolvedScenicId >= 1 else { return nil } let parts = marker.id.split(separator: "-", maxSplits: 1).map(String.init) guard parts.count == 2, let id = Int(parts[1]), id >= 1 else { return nil } diff --git a/suixinkan/UI/CooperationOrder/BindAcquirerViewController.swift b/suixinkan/UI/CooperationOrder/BindAcquirerViewController.swift index 7f86f02..d1aaecb 100644 --- a/suixinkan/UI/CooperationOrder/BindAcquirerViewController.swift +++ b/suixinkan/UI/CooperationOrder/BindAcquirerViewController.swift @@ -150,7 +150,7 @@ final class BindAcquirerViewController: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissionItems()) else { + guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissions.permissionItems()) else { showToast("暂无合作订单权限") navigationController?.popViewController(animated: false) return diff --git a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift index a44ba55..2d4450b 100644 --- a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift +++ b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift @@ -76,7 +76,7 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissionItems()) else { + guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissions.permissionItems()) else { showToast("暂无合作订单权限") navigationController?.popViewController(animated: false) return diff --git a/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift b/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift index e6f168b..e7f23e6 100644 --- a/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift +++ b/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift @@ -118,7 +118,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissionItems()) else { + guard CooperationOrderFeature.hasPermission(in: AppStore.shared.permissions.permissionItems()) else { showToast("暂无合作订单权限") navigationController?.popViewController(animated: false) return diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index 166c816..309b86d 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -201,7 +201,7 @@ final class HomeViewController: BaseViewController { for: indexPath ) as! HomeQuickActionsCell cell.cardView.onCollectPayment = { [weak self] in - guard AppStore.shared.currentScenicId > 0 else { + guard AppStore.shared.session.currentScenicId > 0 else { self?.showToast("请先选择景区") return } diff --git a/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift b/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift index 026bf14..4c4776a 100644 --- a/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift +++ b/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift @@ -42,7 +42,7 @@ final class AllFunctionMenuCell: UICollectionViewCell { /// 更新菜单内容、操作状态及整张卡片的无障碍语义。 func apply(menu: HomeMenuItem, actionStyle: ActionStyle) { - iconView.image = UIImage(systemName: menu.iconName) + iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName) titleLabel.text = menu.title accessibilityLabel = menu.title diff --git a/suixinkan/UI/Live/LiveManageViewController.swift b/suixinkan/UI/Live/LiveManageViewController.swift index 3b16986..6f8fc11 100644 --- a/suixinkan/UI/Live/LiveManageViewController.swift +++ b/suixinkan/UI/Live/LiveManageViewController.swift @@ -485,7 +485,7 @@ private final class AddLiveSheetViewController: UIViewController, PHPickerViewCo loadingIndicator.startAnimating() defer { loadingIndicator.stopAnimating() } do { - let scenicId = AppStore.shared.currentScenicId + let scenicId = AppStore.shared.session.currentScenicId let url = try await uploader.uploadLiveCover( data: media.data, fileName: media.fileName, diff --git a/suixinkan/UI/Login/LoginViewModel.swift b/suixinkan/UI/Login/LoginViewModel.swift index 54f1c6b..305ea95 100644 --- a/suixinkan/UI/Login/LoginViewModel.swift +++ b/suixinkan/UI/Login/LoginViewModel.swift @@ -37,10 +37,10 @@ final class LoginViewModel { /// 从本地存储恢复上次登录手机号与协议勾选状态。 func applyStoredPreferences() { if account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let lastUsername = AppStore.shared.lastLoginUsername { + let lastUsername = AppStore.shared.session.lastLoginUsername { account = lastUsername } - isPrivacyChecked = AppStore.shared.privacyAgreementAccepted + isPrivacyChecked = AppStore.shared.session.privacyAgreementAccepted notifyStateChange() } diff --git a/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift index f039b7b..c7fc3cd 100644 --- a/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift @@ -146,17 +146,15 @@ final class MaterialDetailViewController: BaseViewController { } @objc private func deleteTapped() { - let alert = UIAlertController(title: "确认删除", message: "确定删除该素材吗?", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in + let dialog = MaterialDeleteDialogViewController(itemName: viewModel.detail?.name ?? "") { [weak self] in guard let self else { return } Task { self.showLoading() await self.viewModel.delete(api: self.api) self.hideLoading() } - }) - present(alert, animated: true) + } + present(dialog, animated: true) } @objc private func editTapped() { @@ -170,6 +168,28 @@ final class MaterialDetailViewController: BaseViewController { } } +/// 素材详情无限轮播的页码映射规则。 +struct MaterialCarouselIndexMapper { + /// 初始渲染页;多媒体时跳过头部复制页。 + static func initialRenderedPage(mediaCount: Int) -> Int { + mediaCount > 1 ? 1 : 0 + } + + /// 将头尾复制页归一到对应的真实渲染页。 + static func normalizedRenderedPage(_ page: Int, mediaCount: Int) -> Int { + guard mediaCount > 1 else { return 0 } + if page <= 0 { return mediaCount } + if page >= mediaCount + 1 { return 1 } + return page + } + + /// 将渲染页映射到圆点使用的真实素材索引。 + static func logicalPage(renderedPage: Int, mediaCount: Int) -> Int { + guard mediaCount > 1 else { return 0 } + return normalizedRenderedPage(renderedPage, mediaCount: mediaCount) - 1 + } +} + /// 素材详情媒体轮播,支持图片自动切换与视频播完切换。 /// 素材详情顶部媒体轮播视图,支持图片与视频播放。 private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { @@ -177,8 +197,11 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { private let pageControl = UIPageControl() private let placeholderLabel = UILabel() private var media: [MaterialDetailMediaItem] = [] + private var renderedMedia: [MaterialDetailMediaItem] = [] private var players: [Int: AVPlayer] = [:] + private var playerViews: [Int: MaterialPlayerLayerView] = [:] private var timer: Timer? + private var renderedPage = 0 override init(frame: CGRect) { super.init(frame: frame) @@ -224,7 +247,9 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { NotificationCenter.default.removeObserver(self) players.values.forEach { $0.pause() } players.removeAll() + playerViews.removeAll() self.media = media + renderedMedia = media.count > 1 ? [media.last!] + media + [media.first!] : media scrollView.subviews.forEach { $0.removeFromSuperview() } pageControl.numberOfPages = media.count pageControl.currentPage = 0 @@ -233,7 +258,8 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { scrollView.isHidden = media.isEmpty guard !media.isEmpty else { return } - for (index, item) in media.enumerated() { + renderedPage = MaterialCarouselIndexMapper.initialRenderedPage(mediaCount: media.count) + for (index, item) in renderedMedia.enumerated() { let page = UIView() page.backgroundColor = UIColor(hex: 0xF5F5F5) scrollView.addSubview(page) @@ -245,6 +271,7 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { } setNeedsLayout() layoutIfNeeded() + scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * bounds.width, y: 0) playCurrentPageIfNeeded() } @@ -283,7 +310,8 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { let player = AVPlayer(url: url) players[index] = player let playerView = MaterialPlayerLayerView() - playerView.playerLayer.player = player + playerView.configure(player: player) + playerViews[index] = playerView page.addSubview(playerView) playerView.snp.makeConstraints { make in make.edges.equalToSuperview() @@ -302,29 +330,40 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { for (index, page) in scrollView.subviews.enumerated() { page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height) } - scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height) - scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0) + scrollView.contentSize = CGSize(width: size.width * CGFloat(renderedMedia.count), height: size.height) + scrollView.contentOffset = CGPoint(x: CGFloat(renderedPage) * size.width, y: 0) } private func updateCurrentPage() { guard bounds.width > 0 else { return } - let page = Int(round(scrollView.contentOffset.x / bounds.width)) - pageControl.currentPage = max(0, min(page, media.count - 1)) + let rawPage = Int(round(scrollView.contentOffset.x / bounds.width)) + let page = MaterialCarouselIndexMapper.normalizedRenderedPage(rawPage, mediaCount: media.count) + if media.count > 1 { + if page != rawPage { + scrollView.setContentOffset(CGPoint(x: CGFloat(page) * bounds.width, y: 0), animated: false) + } + pageControl.currentPage = MaterialCarouselIndexMapper.logicalPage(renderedPage: page, mediaCount: media.count) + } else { + pageControl.currentPage = 0 + } + renderedPage = page playCurrentPageIfNeeded() } private func playCurrentPageIfNeeded() { stopTimer() players.forEach { index, player in - if index == pageControl.currentPage { + if index == renderedPage { player.seek(to: .zero) player.play() + playerViews[index]?.setPlaying(true) } else { player.pause() + playerViews[index]?.setPlaying(false) } } - guard !media.isEmpty else { return } - if media[pageControl.currentPage].type == 1 { + guard renderedMedia.indices.contains(renderedPage) else { return } + if renderedMedia[renderedPage].type == 1 { startImageTimer() } } @@ -342,13 +381,13 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { } @objc private func videoDidEnd(_ notification: Notification) { + guard players[renderedPage]?.currentItem === notification.object as? AVPlayerItem else { return } advancePage() } private func advancePage() { guard media.count > 1, bounds.width > 0 else { return } - let nextPage = (pageControl.currentPage + 1) % media.count - pageControl.currentPage = nextPage + let nextPage = renderedPage + 1 scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true) DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in self?.playCurrentPageIfNeeded() @@ -361,6 +400,64 @@ private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate { private final class MaterialPlayerLayerView: UIView { override class var layerClass: AnyClass { AVPlayerLayer.self } var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } + private let controlButton = UIButton(type: .system) + private weak var player: AVPlayer? + private var hideWorkItem: DispatchWorkItem? + + override init(frame: CGRect) { + super.init(frame: frame) + controlButton.tintColor = .white + controlButton.backgroundColor = UIColor.black.withAlphaComponent(0.5) + controlButton.layer.cornerRadius = 24 + controlButton.addTarget(self, action: #selector(controlTapped), for: .touchUpInside) + addSubview(controlButton) + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showControls))) + controlButton.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(48) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(player: AVPlayer) { + self.player = player + playerLayer.player = player + playerLayer.videoGravity = .resizeAspect + setPlaying(false) + } + + func setPlaying(_ playing: Bool) { + controlButton.setImage(UIImage(systemName: playing ? "pause.fill" : "play.fill"), for: .normal) + if playing { + scheduleHide() + } else { + hideWorkItem?.cancel() + controlButton.isHidden = false + } + } + + @objc private func controlTapped() { + guard let player else { return } + if player.timeControlStatus == .playing { + player.pause() + setPlaying(false) + } else { + player.play() + setPlaying(true) + } + } + + @objc private func showControls() { + controlButton.isHidden = false + if player?.timeControlStatus == .playing { scheduleHide() } + } + + private func scheduleHide() { + hideWorkItem?.cancel() + controlButton.isHidden = false + let work = DispatchWorkItem { [weak self] in self?.controlButton.isHidden = true } + hideWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work) + } } /// 素材详情基础信息区。 @@ -370,7 +467,7 @@ private final class MaterialDetailInfoSection: UIView { private let listingBadge = UILabel() private let tagStack = UIStackView() private let statsStack = UIStackView() - private let downloadStat = MaterialDetailStatView(systemIconName: "arrow.down.circle") + private let downloadStat = MaterialDetailStatView(iconName: "material_ic_download_count") private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count") private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count") diff --git a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift index 3fc757f..45c53d1 100644 --- a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift @@ -41,6 +41,7 @@ final class MaterialFormViewController: BaseViewController { private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) private let progressOverlay = MaterialUploadProgressOverlay() + private let dropdownOverlay = MaterialAnchoredDropdownView() private var pickTarget: PickTarget = .material var onSubmitSuccess: (() -> Void)? @@ -106,7 +107,9 @@ final class MaterialFormViewController: BaseViewController { bottomBar.addSubview(cancelButton) bottomBar.addSubview(confirmButton) view.addSubview(progressOverlay) + view.addSubview(dropdownOverlay) progressOverlay.isHidden = true + dropdownOverlay.isHidden = true } override func setupConstraints() { @@ -147,6 +150,9 @@ final class MaterialFormViewController: BaseViewController { progressOverlay.snp.makeConstraints { make in make.edges.equalToSuperview() } + dropdownOverlay.snp.makeConstraints { make in + make.edges.equalToSuperview() + } } override func bindActions() { @@ -167,6 +173,7 @@ final class MaterialFormViewController: BaseViewController { mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside) mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) } mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) } + mediaGridView.onPreview = { [weak self] index in self?.presentMediaPreview(startingAt: index) } coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) } coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() } tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) } @@ -174,7 +181,7 @@ final class MaterialFormViewController: BaseViewController { guard let self else { return } Task { await self.viewModel.addTag(api: self.api) } } - tagView.onDelete = { [weak self] tag in self?.viewModel.deleteTag(tag) } + tagView.onDelete = { [weak self] tag in self?.viewModel.requestDeleteTag(tag) } cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) applyViewModel() @@ -207,6 +214,7 @@ final class MaterialFormViewController: BaseViewController { } else { progressOverlay.isHidden = true } + presentTagDeleteDialogIfNeeded() } private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) { @@ -222,25 +230,30 @@ final class MaterialFormViewController: BaseViewController { } @objc private func spotTapped() { - let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet) - viewModel.scenicSpotList.forEach { spot in - sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in - self?.viewModel.selectScenicSpot(spot) - }) + let spots = viewModel.scenicSpotList + dropdownOverlay.show( + in: view, + anchor: spotButton, + options: spots.map(\.name), + selectedIndex: spots.firstIndex { $0.id == viewModel.selectedScenicSpot?.id }, + maximumHeight: 128 + ) { [weak self] index in + guard spots.indices.contains(index) else { return } + self?.viewModel.selectScenicSpot(spots[index]) } - sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(sheet, animated: true) } @objc private func mediaTypeTapped() { - let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet) - MaterialMediaType.allCases.forEach { type in - sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in - self?.viewModel.selectMediaType(type) - }) + let types = MaterialMediaType.allCases + dropdownOverlay.show( + in: view, + anchor: mediaTypeButton, + options: types.map(\.title), + selectedIndex: types.firstIndex(of: viewModel.mediaType) + ) { [weak self] index in + guard types.indices.contains(index) else { return } + self?.viewModel.selectMediaType(types[index]) } - sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(sheet, animated: true) } @objc private func cancelTapped() { @@ -267,24 +280,29 @@ final class MaterialFormViewController: BaseViewController { sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in self?.presentPhotoPicker() }) - if target == .material { - sheet.addAction(UIAlertAction(title: "从云盘导入", style: .default) { [weak self] _ in - self?.openCloudPicker() - }) - } sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) present(sheet, animated: true) } - private func openCloudPicker() { - let controller = CloudStoragePickForTaskViewController( - importedFileIDs: viewModel.importedCloudFileIDs, - allowedFileType: viewModel.mediaType.cloudFileType - ) - controller.onConfirmed = { [weak self] files in - self?.viewModel.addCloudMediaFiles(files) + private func presentMediaPreview(startingAt sourceIndex: Int) { + let completed = viewModel.uploadedMediaList.enumerated().filter { !$0.element.isUploading && !$0.element.ossUrl.isEmpty } + guard let startIndex = completed.firstIndex(where: { $0.offset == sourceIndex }) else { return } + let items = completed.map { + MaterialPreviewItem(url: $0.element.ossUrl, mediaType: viewModel.mediaType) } - navigationController?.pushViewController(controller, animated: true) + present(MaterialMediaPreviewViewController(items: items, startIndex: startIndex), animated: true) + } + + private func presentTagDeleteDialogIfNeeded() { + guard let tag = viewModel.pendingDeleteTag, presentedViewController == nil else { return } + let dialog = MaterialDeleteDialogViewController( + title: "删除标签", + itemName: tag.name, + itemSuffix: "标签吗?", + onCancel: { [weak self] in self?.viewModel.cancelDeleteTag() }, + onConfirm: { [weak self] in self?.viewModel.confirmDeleteTag() } + ) + present(dialog, animated: true) } private func presentPhotoPicker() { @@ -462,7 +480,7 @@ private final class MaterialFormFieldView: UIView { /// 带标题和值的表单选择控件。 private final class MaterialSelectButton: UIControl { private let titleLabel = UILabel() - private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down")) + private let chevronView = UIImageView(image: UIImage(named: "material_ic_arrow_down")) override init(frame: CGRect) { super.init(frame: frame) @@ -497,12 +515,92 @@ private final class MaterialSelectButton: UIControl { } } +/// 锚定在表单选择框下方的 Android 风格下拉菜单。 +private final class MaterialAnchoredDropdownView: UIControl { + private let cardView = UIView() + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private var onSelect: ((Int) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 4 + cardView.layer.shadowColor = UIColor.black.cgColor + cardView.layer.shadowOpacity = 0.14 + cardView.layer.shadowRadius = 8 + cardView.layer.shadowOffset = CGSize(width: 0, height: 4) + stackView.axis = .vertical + addSubview(cardView) + cardView.addSubview(scrollView) + scrollView.addSubview(stackView) + addTarget(self, action: #selector(dismissMenu), for: .touchUpInside) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 在指定控件下方展示菜单。 + func show( + in container: UIView, + anchor: UIView, + options: [String], + selectedIndex: Int?, + maximumHeight: CGFloat = 128, + onSelect: @escaping (Int) -> Void + ) { + guard !options.isEmpty else { return } + self.onSelect = onSelect + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + for (index, option) in options.enumerated() { + let button = UIButton(type: .system) + button.tag = index + button.contentHorizontalAlignment = .leading + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + button.setTitle(option, for: .normal) + button.setTitleColor(index == selectedIndex ? AppColor.primary : .black, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: index == selectedIndex ? .medium : .regular) + button.backgroundColor = .white + button.addTarget(self, action: #selector(optionTapped(_:)), for: .touchUpInside) + button.snp.makeConstraints { make in make.height.equalTo(28) } + stackView.addArrangedSubview(button) + } + + isHidden = false + container.bringSubviewToFront(self) + layoutIfNeeded() + let anchorFrame = anchor.convert(anchor.bounds, to: self) + let menuHeight = min(maximumHeight, CGFloat(options.count) * 28) + cardView.frame = CGRect(x: anchorFrame.minX, y: anchorFrame.maxY, width: anchorFrame.width, height: menuHeight) + scrollView.frame = cardView.bounds + stackView.frame = CGRect(x: 0, y: 0, width: cardView.bounds.width, height: CGFloat(options.count) * 28) + scrollView.contentSize = stackView.frame.size + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + isHidden ? false : true + } + + @objc private func optionTapped(_ sender: UIButton) { + isHidden = true + onSelect?(sender.tag) + } + + @objc private func dismissMenu() { + isHidden = true + } +} + /// 素材上传网格。 /// 素材媒体上传网格视图。 private final class MaterialUploadGridView: UIView { private let stack = UIStackView() var onAdd: (() -> Void)? var onDelete: ((Int) -> Void)? + var onPreview: ((Int) -> Void)? override init(frame: CGRect) { super.init(frame: frame) @@ -550,6 +648,7 @@ private final class MaterialUploadGridView: UIView { if let item { cell.apply(item: item) cell.onDelete = { [weak self] in self?.onDelete?(index) } + cell.onPreview = { [weak self] in self?.onPreview?(index) } } else { cell.applyAdd() cell.onAdd = { [weak self] in self?.onAdd?() } @@ -563,14 +662,17 @@ private final class MaterialUploadGridView: UIView { private final class MaterialUploadThumbView: UIControl { private let imageView = UIImageView() private let addCircle = UIView() - private let addIcon = UIImageView(image: UIImage(systemName: "plus")) + private let addIcon = UIImageView(image: UIImage(named: "material_ic_add")) private let textLabel = UILabel() private let deleteButton = UIButton(type: .system) - private let playIcon = UIImageView(image: UIImage(systemName: "play.fill")) + private let playIcon = UIImageView(image: UIImage(named: "material_ic_play")) private let mediaType: MaterialMediaType + private let dashedLayer = CAShapeLayer() + private var previewEnabled = false var onAdd: (() -> Void)? var onDelete: (() -> Void)? + var onPreview: (() -> Void)? init(mediaType: MaterialMediaType) { self.mediaType = mediaType @@ -586,7 +688,7 @@ private final class MaterialUploadThumbView: UIControl { textLabel.font = .systemFont(ofSize: 12) textLabel.textColor = UIColor(hex: 0x4B5563) textLabel.textAlignment = .center - deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal) + deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal) deleteButton.tintColor = .white deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) deleteButton.layer.cornerRadius = 10 @@ -622,7 +724,12 @@ private final class MaterialUploadThumbView: UIControl { make.center.equalToSuperview() make.size.equalTo(32) } - addTarget(self, action: #selector(addTapped), for: .touchUpInside) + dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor + dashedLayer.fillColor = UIColor.clear.cgColor + dashedLayer.lineWidth = 1 + dashedLayer.lineDashPattern = [6, 6] + layer.addSublayer(dashedLayer) + addTarget(self, action: #selector(cellTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } @@ -631,12 +738,19 @@ private final class MaterialUploadThumbView: UIControl { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { + super.layoutSubviews() + dashedLayer.frame = bounds + dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath + } + func applyAdd() { imageView.image = nil imageView.kf.cancelDownloadTask() backgroundColor = .white - layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor - layer.borderWidth = 1 + layer.borderWidth = 0 + dashedLayer.isHidden = false + previewEnabled = false addCircle.isHidden = false textLabel.isHidden = false textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频" @@ -646,7 +760,8 @@ private final class MaterialUploadThumbView: UIControl { } func apply(item: MaterialUploadedMediaItem) { - layer.borderWidth = 0 + dashedLayer.isHidden = true + previewEnabled = !item.ossUrl.isEmpty && !item.isUploading backgroundColor = UIColor(hex: 0xF5F5F5) addCircle.isHidden = true deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading @@ -658,7 +773,14 @@ private final class MaterialUploadThumbView: UIControl { textLabel.numberOfLines = 2 } else { textLabel.isHidden = true - if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty { + if mediaType == .video { + if let thumbnailData = item.thumbnailData, let image = UIImage(data: thumbnailData) { + imageView.image = image + } else if let urlText = (item.previewURL?.isEmpty == false ? item.previewURL : item.ossUrl), + !urlText.isEmpty, let url = URL(string: urlText) { + loadVideoThumbnail(url: url) + } + } else if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty { imageView.kf.setImage(with: url) } else { imageView.image = UIImage(data: item.thumbnailData ?? item.data) @@ -666,14 +788,27 @@ private final class MaterialUploadThumbView: UIControl { } } - @objc private func addTapped() { - guard !addCircle.isHidden else { return } - onAdd?() + @objc private func cellTapped() { + if !addCircle.isHidden { + onAdd?() + } else if previewEnabled { + onPreview?() + } } @objc private func deleteTapped() { onDelete?() } + + private func loadVideoThumbnail(url: URL) { + imageView.image = nil + let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url)) + generator.appliesPreferredTrackTransform = true + generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: .zero)]) { [weak self] _, image, _, _, _ in + guard let image else { return } + DispatchQueue.main.async { self?.imageView.image = UIImage(cgImage: image) } + } + } } /// 素材封面上传视图。 @@ -681,18 +816,22 @@ private final class MaterialUploadThumbView: UIControl { private final class MaterialCoverUploadView: UIControl { private let imageView = UIImageView() private let addCircle = UIView() - private let addIcon = UIImageView(image: UIImage(systemName: "plus")) + private let addIcon = UIImageView(image: UIImage(named: "material_ic_add")) private let titleLabel = UILabel() private let hintLabel = UILabel() private let deleteButton = UIButton(type: .system) + private let dashedLayer = CAShapeLayer() var onAdd: (() -> Void)? var onDelete: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 8 - layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor - layer.borderWidth = 1 + dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor + dashedLayer.fillColor = UIColor.clear.cgColor + dashedLayer.lineWidth = 1 + dashedLayer.lineDashPattern = [6, 6] + layer.addSublayer(dashedLayer) clipsToBounds = true imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true @@ -707,7 +846,7 @@ private final class MaterialCoverUploadView: UIControl { hintLabel.font = .systemFont(ofSize: 12) hintLabel.textColor = UIColor(hex: 0xB6BECA) hintLabel.textAlignment = .center - deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal) + deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal) deleteButton.tintColor = .white deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) deleteButton.layer.cornerRadius = 12 @@ -753,6 +892,12 @@ private final class MaterialCoverUploadView: UIControl { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { + super.layoutSubviews() + dashedLayer.frame = bounds + dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath + } + func apply(item: MaterialUploadedMediaItem?) { guard let item else { imageView.image = nil @@ -763,6 +908,7 @@ private final class MaterialCoverUploadView: UIControl { titleLabel.numberOfLines = 1 hintLabel.isHidden = false deleteButton.isHidden = true + dashedLayer.isHidden = false return } if item.isUploading { @@ -773,6 +919,7 @@ private final class MaterialCoverUploadView: UIControl { titleLabel.numberOfLines = 2 hintLabel.isHidden = true deleteButton.isHidden = true + dashedLayer.isHidden = true } else { if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty { imageView.kf.setImage(with: url) @@ -783,6 +930,7 @@ private final class MaterialCoverUploadView: UIControl { titleLabel.isHidden = true hintLabel.isHidden = true deleteButton.isHidden = item.ossUrl.isEmpty + dashedLayer.isHidden = true } } @@ -819,17 +967,17 @@ private final class MaterialTagEditorView: UIView { tagsStack.alignment = .leading inputRow.axis = .horizontal inputRow.spacing = 8 - textField.placeholder = "请输入标签" textField.font = .systemFont(ofSize: 14) textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor textField.layer.borderWidth = 1 - textField.layer.cornerRadius = 8 + textField.layer.cornerRadius = 4 textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) textField.leftViewMode = .always - addButton.setTitle("添加", for: .normal) + textField.placeholder = "请输入标签,最低5个字符" + addButton.setTitle("添加标签", for: .normal) addButton.setTitleColor(.white, for: .normal) addButton.backgroundColor = AppColor.primary - addButton.layer.cornerRadius = 8 + addButton.layer.cornerRadius = 4 addSubview(stack) stack.addArrangedSubview(tagsStack) stack.addArrangedSubview(inputRow) @@ -839,11 +987,11 @@ private final class MaterialTagEditorView: UIView { make.edges.equalToSuperview() } textField.snp.makeConstraints { make in - make.height.equalTo(44) + make.height.equalTo(36) } addButton.snp.makeConstraints { make in - make.width.equalTo(72) - make.height.equalTo(44) + make.width.equalTo(80) + make.height.equalTo(36) } textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside) @@ -859,10 +1007,13 @@ private final class MaterialTagEditorView: UIView { if textField.text != input { textField.text = input } + let canAdd = (5 ... 15).contains(input.count) + addButton.isEnabled = canAdd + addButton.backgroundColor = canAdd ? AppColor.primary : AppColor.buttonDisabled tagsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } tags.forEach { tag in let button = UIButton(type: .system) - button.setTitle("\(tag.name) ×", for: .normal) + button.setTitle(tag.name, for: .normal) button.setTitleColor(AppColor.primary, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 12) button.backgroundColor = UIColor(hex: 0xEFF6FF) diff --git a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift index b17995d..f10e44e 100644 --- a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift @@ -15,16 +15,15 @@ final class MaterialListViewController: BaseViewController { private let searchContainer = UIView() private let searchBox = UIView() - private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass")) + private let searchIconView = UIImageView(image: UIImage(named: "material_ic_search")) private let searchTextField = UITextField() private let filterContainer = UIView() private let filterButton = UIButton(type: .system) private let filterTitleLabel = UILabel() - private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down")) + private let filterChevronView = UIImageView(image: UIImage(named: "material_ic_arrow_down")) private let filterDropdownView = UIView() private let filterDropdownStack = UIStackView() private let tableView = UITableView(frame: .zero, style: .plain) - private let emptyLabel = UILabel() private let bottomBar = UIView() private let addButton = UIButton(type: .system) private var dataSource: UITableViewDiffableDataSource! @@ -115,12 +114,6 @@ final class MaterialListViewController: BaseViewController { } } - emptyLabel.text = "暂无素材" - emptyLabel.font = .systemFont(ofSize: 16) - emptyLabel.textColor = AppColor.textSecondary - emptyLabel.textAlignment = .center - emptyLabel.isHidden = true - bottomBar.backgroundColor = .white addButton.setTitle("添加", for: .normal) addButton.setTitleColor(.white, for: .normal) @@ -137,7 +130,6 @@ final class MaterialListViewController: BaseViewController { filterButton.addSubview(filterTitleLabel) filterButton.addSubview(filterChevronView) view.addSubview(tableView) - view.addSubview(emptyLabel) view.addSubview(bottomBar) bottomBar.addSubview(addButton) view.addSubview(filterDropdownView) @@ -206,11 +198,6 @@ final class MaterialListViewController: BaseViewController { filterDropdownStack.snp.makeConstraints { make in make.edges.equalToSuperview() } - emptyLabel.snp.makeConstraints { make in - make.centerX.equalToSuperview() - make.centerY.equalTo(tableView) - make.leading.trailing.equalToSuperview().inset(24) - } } override func bindActions() { @@ -242,7 +229,7 @@ final class MaterialListViewController: BaseViewController { @MainActor private func applyViewModel() { - filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title + filterTitleLabel.text = "筛选" filterOptionButtons.forEach { status, button in let selected = status == viewModel.filterStatus button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal) @@ -256,7 +243,6 @@ final class MaterialListViewController: BaseViewController { snapshot.appendSections([0]) snapshot.appendItems([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item)) dataSource.apply(snapshot, animatingDifferences: true) - emptyLabel.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading() if !viewModel.isRefreshing { tableView.refreshControl?.endRefreshing() @@ -371,6 +357,10 @@ private final class MaterialStatsCell: UITableViewCell { contentView.backgroundColor = AppColor.pageBackground cardView.backgroundColor = .white cardView.layer.cornerRadius = 12 + cardView.layer.shadowColor = UIColor.black.cgColor + cardView.layer.shadowOpacity = 0.12 + cardView.layer.shadowRadius = 2 + cardView.layer.shadowOffset = CGSize(width: 0, height: 1) stack.axis = .horizontal stack.spacing = 12 stack.distribution = .fillEqually @@ -451,7 +441,7 @@ private final class MaterialListCell: UITableViewCell { private let statusBadge = MaterialStatusBadgeView() private let listingSwitch = UISwitch() private let statsStack = UIStackView() - private let downloadStatView = MaterialStatView(systemIconName: "arrow.down.circle") + private let downloadStatView = MaterialStatView(iconName: "material_ic_download_count") private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count") private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count") private var isApplying = false diff --git a/suixinkan/UI/MaterialManagement/MaterialManagementDialogs.swift b/suixinkan/UI/MaterialManagement/MaterialManagementDialogs.swift new file mode 100644 index 0000000..e216c07 --- /dev/null +++ b/suixinkan/UI/MaterialManagement/MaterialManagementDialogs.swift @@ -0,0 +1,258 @@ +// +// MaterialManagementDialogs.swift +// suixinkan +// + +import AVKit +import Kingfisher +import SnapKit +import UIKit + +/// 素材模块 Android 风格红色删除确认弹窗。 +final class MaterialDeleteDialogViewController: UIViewController { + private let dialogTitle: String + private let itemName: String + private let itemSuffix: String + private let onConfirm: () -> Void + private let onCancel: () -> Void + private let dimView = UIView() + private let cardView = UIView() + + /// 创建素材删除确认弹窗。 + init( + title: String = "删除素材", + itemName: String, + itemSuffix: String = "素材吗?", + onCancel: @escaping () -> Void = {}, + onConfirm: @escaping () -> Void + ) { + dialogTitle = title + self.itemName = itemName + self.itemSuffix = itemSuffix + self.onConfirm = onConfirm + self.onCancel = onCancel + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + dimView.backgroundColor = UIColor.black.withAlphaComponent(0.32) + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 16 + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 16 + + let titleLabel = UILabel() + titleLabel.text = dialogTitle + titleLabel.font = .systemFont(ofSize: 18, weight: .bold) + titleLabel.textColor = UIColor(hex: 0xFF0000) + + let messageLabel = UILabel() + messageLabel.numberOfLines = 0 + let prefix = "您确定要删除" + let suffix = itemSuffix + let message = NSMutableAttributedString( + string: prefix + itemName + suffix, + attributes: [ + .font: UIFont.systemFont(ofSize: 16), + .foregroundColor: UIColor(hex: 0x333333), + ] + ) + if !itemName.isEmpty { + let prefixLength = (prefix as NSString).length + let itemLength = (itemName as NSString).length + message.addAttribute( + .font, + value: UIFont.systemFont(ofSize: 16, weight: .bold), + range: NSRange(location: prefixLength, length: itemLength) + ) + } + messageLabel.attributedText = message + + let buttons = UIStackView() + buttons.axis = .horizontal + buttons.spacing = 12 + buttons.distribution = .fillEqually + let cancelButton = makeButton(title: "取消", titleColor: UIColor(hex: 0xFF0000), background: UIColor(hex: 0xFFE5E5)) + let confirmButton = makeButton(title: "确认", titleColor: .white, background: UIColor(hex: 0xFF0000)) + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + [cancelButton, confirmButton].forEach { button in + button.snp.makeConstraints { make in make.height.equalTo(44) } + buttons.addArrangedSubview(button) + } + + view.addSubview(dimView) + view.addSubview(cardView) + cardView.addSubview(stack) + [titleLabel, messageLabel, buttons].forEach(stack.addArrangedSubview) + stack.setCustomSpacing(24, after: messageLabel) + dimView.snp.makeConstraints { make in make.edges.equalToSuperview() } + cardView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(32) + } + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(24) } + } + + private func makeButton(title: String, titleColor: UIColor, background: UIColor) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(titleColor, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + button.backgroundColor = background + button.layer.cornerRadius = 8 + return button + } + + @objc private func cancelTapped() { + dismiss(animated: true) { [onCancel] in onCancel() } + } + + @objc private func confirmTapped() { + dismiss(animated: true) { [onConfirm] in onConfirm() } + } +} + +/// 素材预览数据,统一描述图片和视频。 +struct MaterialPreviewItem: Equatable { + let url: String + let mediaType: MaterialMediaType +} + +/// Android `MediaPreviewDialog` 对齐的全屏素材预览页。 +final class MaterialMediaPreviewViewController: UIViewController, UIScrollViewDelegate { + private let items: [MaterialPreviewItem] + private let startIndex: Int + private let scrollView = UIScrollView() + private let closeButton = UIButton(type: .system) + private var pages: [UIView] = [] + private var players: [Int: AVPlayerViewController] = [:] + private var currentIndex = 0 + + /// 创建全屏素材预览页。 + init(items: [MaterialPreviewItem], startIndex: Int) { + self.items = items + self.startIndex = max(0, min(startIndex, max(0, items.count - 1))) + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + scrollView.backgroundColor = .black + scrollView.isPagingEnabled = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.bounces = false + scrollView.delegate = self + closeButton.setImage(UIImage(named: "material_ic_clear"), for: .normal) + closeButton.tintColor = .white + closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.5) + closeButton.layer.cornerRadius = 22 + closeButton.accessibilityLabel = "关闭" + closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) + + view.addSubview(scrollView) + view.addSubview(closeButton) + scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() } + closeButton.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(5) + make.trailing.equalToSuperview().inset(16) + make.size.equalTo(44) + } + buildPages() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let size = scrollView.bounds.size + guard size.width > 0, size.height > 0 else { return } + for (index, page) in pages.enumerated() { + page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height) + } + scrollView.contentSize = CGSize(width: CGFloat(items.count) * size.width, height: size.height) + scrollView.contentOffset = CGPoint(x: CGFloat(currentIndex) * size.width, y: 0) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + currentIndex = startIndex + view.setNeedsLayout() + view.layoutIfNeeded() + playCurrentVideo() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + players.values.forEach { $0.player?.pause() } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateCurrentIndex() + } + + private func buildPages() { + for (index, item) in items.enumerated() { + let page = UIView() + page.backgroundColor = .black + scrollView.addSubview(page) + pages.append(page) + if item.mediaType == .video, let url = URL(string: item.url) { + let controller = AVPlayerViewController() + controller.player = AVPlayer(url: url) + controller.showsPlaybackControls = true + addChild(controller) + page.addSubview(controller.view) + controller.view.snp.makeConstraints { make in make.edges.equalToSuperview() } + controller.didMove(toParent: self) + players[index] = controller + } else { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.backgroundColor = .black + page.addSubview(imageView) + imageView.snp.makeConstraints { make in make.edges.equalToSuperview() } + if let url = URL(string: item.url) { + imageView.kf.setImage(with: url) + } + } + } + } + + private func updateCurrentIndex() { + guard scrollView.bounds.width > 0 else { return } + currentIndex = max(0, min(Int(round(scrollView.contentOffset.x / scrollView.bounds.width)), items.count - 1)) + playCurrentVideo() + } + + private func playCurrentVideo() { + players.forEach { index, controller in + if index == currentIndex { + controller.player?.play() + } else { + controller.player?.pause() + } + } + } + + @objc private func closeTapped() { + dismiss(animated: true) + } +} diff --git a/suixinkan/UI/Orders/DepositOrderDetailViewController.swift b/suixinkan/UI/Orders/DepositOrderDetailViewController.swift index 231963d..d0a0a44 100644 --- a/suixinkan/UI/Orders/DepositOrderDetailViewController.swift +++ b/suixinkan/UI/Orders/DepositOrderDetailViewController.swift @@ -41,7 +41,7 @@ final class DepositOrderDetailViewController: BaseViewController, UITableViewDat } private func loadDetail() async { - let storeId = AppStore.shared.currentStoreId + let storeId = AppStore.shared.session.currentStoreId guard storeId > 0 else { showToast("店铺信息异常") return diff --git a/suixinkan/UI/Orders/OrdersViewController.swift b/suixinkan/UI/Orders/OrdersViewController.swift index 9c5122f..50ee754 100644 --- a/suixinkan/UI/Orders/OrdersViewController.swift +++ b/suixinkan/UI/Orders/OrdersViewController.swift @@ -18,7 +18,7 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT private let listView = OrderListView() private var hasInitialized = false - private var isStoreMode: Bool { AppStore.shared.currentAppRole == .storeAdmin } + private var isStoreMode: Bool { AppStore.shared.session.currentAppRole == .storeAdmin } private var pendingOrderSourceNumber: String? private weak var orderSourcePicker: OrderSourcePickerViewController? private var pendingGiftOrderNumber: String? diff --git a/suixinkan/UI/Profile/AccountSwitchViewController.swift b/suixinkan/UI/Profile/AccountSwitchViewController.swift index 6a4b568..0e74f0f 100644 --- a/suixinkan/UI/Profile/AccountSwitchViewController.swift +++ b/suixinkan/UI/Profile/AccountSwitchViewController.swift @@ -97,13 +97,13 @@ final class AccountSwitchViewController: BaseViewController, UITableViewDelegate private var currentAccountId: String? { let store = AppStore.shared - if store.currentStoreId > 0 { - return "\(V9StoreUser.accountTypeValue)_\(store.currentStoreId)" + if store.session.currentStoreId > 0 { + return "\(V9StoreUser.accountTypeValue)_\(store.session.currentStoreId)" } - if store.currentScenicId > 0 { - return "\(V9ScenicUser.accountTypeValue)_\(store.userId)" + if store.session.currentScenicId > 0 { + return "\(V9ScenicUser.accountTypeValue)_\(store.session.userId)" } - return store.userId.isEmpty ? nil : store.userId + return store.session.userId.isEmpty ? nil : store.session.userId } private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool { diff --git a/suixinkan/UI/Profile/ProfileEditViewController.swift b/suixinkan/UI/Profile/ProfileEditViewController.swift index 7c5be54..3a31eba 100644 --- a/suixinkan/UI/Profile/ProfileEditViewController.swift +++ b/suixinkan/UI/Profile/ProfileEditViewController.swift @@ -156,7 +156,7 @@ final class ProfileEditViewController: BaseViewController { } private func saveProfile() async { - let scenicId = AppStore.shared.currentScenicId + let scenicId = AppStore.shared.session.currentScenicId guard scenicId > 0 else { showToast("当前景区信息缺失") return diff --git a/suixinkan/UI/Profile/ProfileSpaceSettingsDialogs.swift b/suixinkan/UI/Profile/ProfileSpaceSettingsDialogs.swift new file mode 100644 index 0000000..4a28ce5 --- /dev/null +++ b/suixinkan/UI/Profile/ProfileSpaceSettingsDialogs.swift @@ -0,0 +1,448 @@ +// +// ProfileSpaceSettingsDialogs.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 空间设置使用的 Android 风格图标集合。 +enum ProfileSpaceIcon { + /// 蓝色 16pt 编辑图标。 + static var edit: UIImage? { + UIImage(named: "profile_edit") + } +} + +/// 空间设置链路使用的 Android 风格导航栏外观。 +enum ProfileNavigationStyle { + static func apply(to navigationController: UINavigationController?, showsDivider: Bool) { + guard let navigationBar = navigationController?.navigationBar else { return } + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .white + appearance.shadowColor = showsDivider ? AppColor.border : .clear + appearance.titleTextAttributes = [ + .foregroundColor: UIColor(hex: 0x333333), + .font: UIFont.systemFont(ofSize: 18), + ] + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.tintColor = .black + } + + static func restore(_ navigationController: UINavigationController?) { + guard let navigationBar = navigationController?.navigationBar else { return } + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = AppColor.cardBackground + appearance.shadowColor = .clear + appearance.titleTextAttributes = [ + .foregroundColor: AppColor.textPrimary, + .font: UIFont.app(.title), + ] + navigationBar.standardAppearance = appearance + navigationBar.scrollEdgeAppearance = appearance + navigationBar.compactAppearance = appearance + navigationBar.tintColor = AppColor.primary + } +} + +/// 绘制 Android 风格虚线边框的按钮。 +final class ProfileDashedBorderButton: UIButton { + private let dashedLayer = CAShapeLayer() + + override init(frame: CGRect) { + super.init(frame: frame) + dashedLayer.strokeColor = AppColor.primary.cgColor + dashedLayer.fillColor = UIColor.clear.cgColor + dashedLayer.lineWidth = 1 + dashedLayer.lineDashPattern = [2, 2] + layer.addSublayer(dashedLayer) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + dashedLayer.frame = bounds + dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 4).cgPath + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds.insetBy(dx: -8, dy: -8).contains(point) + } +} + +/// Android 风格弹窗基类,提供遮罩、白色卡片和无动画关闭能力。 +class ProfileBaseDialogViewController: UIViewController { + let cardView = UIView() + + init() { + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor.black.withAlphaComponent(0.2) + cardView.backgroundColor = .white + view.addSubview(cardView) + let outsideTap = UITapGestureRecognizer(target: self, action: #selector(outsideTapped(_:))) + outsideTap.cancelsTouchesInView = false + view.addGestureRecognizer(outsideTap) + cardView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(32) + } + } + + func makePrimaryButton(title: String, height: CGFloat = 45) -> UIButton { + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.setTitleColor(.white, for: .normal) + button.setTitleColor(.white, for: .disabled) + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium) + button.backgroundColor = AppColor.primary + button.layer.cornerRadius = 10 + button.snp.makeConstraints { make in make.height.equalTo(height) } + return button + } + + @objc private func outsideTapped(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: view) + guard !cardView.frame.contains(location) else { return } + dismiss(animated: false) + } +} + +/// Android 风格时间滚轮弹窗。 +final class ProfileTimePickerDialogViewController: ProfileBaseDialogViewController { + private let picker = UIDatePicker() + private let confirmButton: UIButton + private let onConfirm: (Date) -> Void + + init(initialDate: Date, onConfirm: @escaping (Date) -> Void) { + self.onConfirm = onConfirm + confirmButton = UIButton(type: .system) + super.init() + picker.date = initialDate + } + + override func viewDidLoad() { + super.viewDidLoad() + cardView.layer.cornerRadius = 10 + picker.datePickerMode = .time + picker.preferredDatePickerStyle = .wheels + picker.locale = Locale(identifier: "zh_CN") + + confirmButton.setTitle("确认", for: .normal) + confirmButton.setTitleColor(.white, for: .normal) + confirmButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium) + confirmButton.backgroundColor = AppColor.primary + confirmButton.layer.cornerRadius = 10 + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + + cardView.addSubview(picker) + cardView.addSubview(confirmButton) + picker.snp.makeConstraints { make in + make.top.equalToSuperview().offset(24) + make.leading.trailing.equalToSuperview().inset(15) + make.height.equalTo(180) + } + confirmButton.snp.makeConstraints { make in + make.top.equalTo(picker.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(15) + make.height.equalTo(45) + make.bottom.equalToSuperview().inset(24) + } + } + + @objc private func confirmTapped() { + onConfirm(picker.date) + dismiss(animated: false) + } +} + +/// Android 风格相机设备输入弹窗。 +final class ProfileDeviceDialogViewController: ProfileBaseDialogViewController, UITextFieldDelegate { + private let textField = UITextField() + private let confirmButton = UIButton(type: .system) + private let onConfirm: (String) -> Void + + init(onConfirm: @escaping (String) -> Void) { + self.onConfirm = onConfirm + super.init() + } + + override func viewDidLoad() { + super.viewDidLoad() + cardView.layer.cornerRadius = 10 + textField.placeholder = "请输入设备名" + textField.font = .systemFont(ofSize: 14) + textField.layer.borderColor = AppColor.border.cgColor + textField.layer.borderWidth = 1 + textField.layer.cornerRadius = 8 + textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + textField.leftViewMode = .always + textField.returnKeyType = .done + textField.delegate = self + textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) + + confirmButton.setTitle("确认", for: .normal) + confirmButton.setTitleColor(.white, for: .normal) + confirmButton.setTitleColor(.white, for: .disabled) + confirmButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .medium) + confirmButton.layer.cornerRadius = 10 + confirmButton.isEnabled = false + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + + cardView.addSubview(textField) + cardView.addSubview(confirmButton) + textField.snp.makeConstraints { make in + make.top.equalToSuperview().offset(24) + make.leading.trailing.equalToSuperview().inset(15) + make.height.equalTo(46) + } + confirmButton.snp.makeConstraints { make in + make.top.equalTo(textField.snp.bottom).offset(24) + make.leading.trailing.equalToSuperview().inset(15) + make.height.equalTo(45) + make.bottom.equalToSuperview().inset(24) + } + textChanged() + DispatchQueue.main.async { [weak self] in self?.textField.becomeFirstResponder() } + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + guard confirmButton.isEnabled else { return false } + confirmTapped() + return true + } + + @objc private func textChanged() { + let enabled = !(textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + confirmButton.isEnabled = enabled + confirmButton.backgroundColor = enabled ? AppColor.primary : AppColor.buttonDisabled + } + + @objc private func confirmTapped() { + let value = (textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + onConfirm(value) + dismiss(animated: false) + } +} + +/// Android 风格红色删除确认弹窗。 +final class ProfileDeleteDialogViewController: ProfileBaseDialogViewController { + private let dialogTitle: String + private let dialogMessage: String + private let onConfirm: () -> Void + + init(title: String, message: String, onConfirm: @escaping () -> Void) { + dialogTitle = title + dialogMessage = message + self.onConfirm = onConfirm + super.init() + } + + override func viewDidLoad() { + super.viewDidLoad() + cardView.layer.cornerRadius = 16 + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 16 + stack.layoutMargins = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24) + stack.isLayoutMarginsRelativeArrangement = true + + let titleLabel = UILabel() + titleLabel.text = dialogTitle + titleLabel.font = .systemFont(ofSize: 18, weight: .bold) + titleLabel.textColor = UIColor(hex: 0xFF0000) + let messageLabel = UILabel() + messageLabel.font = .systemFont(ofSize: 16) + messageLabel.textColor = UIColor(hex: 0x333333) + messageLabel.numberOfLines = 0 + let message = NSMutableAttributedString( + string: dialogMessage, + attributes: [.font: UIFont.systemFont(ofSize: 16), .foregroundColor: UIColor(hex: 0x333333)] + ) + let prefix = "您确定要删除" + if dialogMessage.hasPrefix(prefix), dialogTitle != "删除日程" { + let suffixes = ["标签吗?", "拍摄说明吗?", "设备吗?"] + if let suffix = suffixes.first(where: dialogMessage.hasSuffix) { + let nameStart = prefix.count + let nameLength = dialogMessage.count - prefix.count - suffix.count + if nameLength > 0 { + message.addAttribute(.font, value: UIFont.systemFont(ofSize: 16, weight: .bold), range: NSRange(location: nameStart, length: nameLength)) + } + } + } + messageLabel.attributedText = message + + let buttons = UIStackView() + buttons.axis = .horizontal + buttons.spacing = 12 + buttons.distribution = .fillEqually + let cancel = UIButton(type: .system) + cancel.setTitle("取消", for: .normal) + cancel.setTitleColor(UIColor(hex: 0xFF0000), for: .normal) + cancel.backgroundColor = UIColor(hex: 0xFFE5E5) + cancel.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + cancel.layer.cornerRadius = 8 + cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + let confirm = UIButton(type: .system) + confirm.setTitle("确认", for: .normal) + confirm.setTitleColor(.white, for: .normal) + confirm.backgroundColor = UIColor(hex: 0xFF0000) + confirm.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + confirm.layer.cornerRadius = 8 + confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + [cancel, confirm].forEach { button in + button.snp.makeConstraints { make in make.height.equalTo(44) } + buttons.addArrangedSubview(button) + } + + stack.addArrangedSubview(titleLabel) + stack.addArrangedSubview(messageLabel) + stack.setCustomSpacing(24, after: messageLabel) + stack.addArrangedSubview(buttons) + cardView.addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview() } + } + + @objc private func cancelTapped() { dismiss(animated: false) } + + @objc private func confirmTapped() { + dismiss(animated: false) { [onConfirm] in onConfirm() } + } +} + +/// 支持缩放和平移的正方形头像裁剪页面,输出 Android 要求的 480×480 JPEG。 +final class ProfileAvatarCropViewController: UIViewController, UIScrollViewDelegate { + private let sourceImage: UIImage + private let onConfirm: (Data) -> Void + private let scrollView = UIScrollView() + private let imageView = UIImageView() + private let cancelButton = UIButton(type: .system) + private let confirmButton = UIButton(type: .system) + private var didConfigureZoom = false + + init(image: UIImage, onConfirm: @escaping (Data) -> Void) { + sourceImage = Self.normalized(image) + self.onConfirm = onConfirm + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + scrollView.delegate = self + scrollView.clipsToBounds = true + scrollView.layer.borderColor = UIColor.white.cgColor + scrollView.layer.borderWidth = 1 + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + imageView.image = sourceImage + imageView.contentMode = .scaleAspectFit + imageView.frame = CGRect(origin: .zero, size: sourceImage.size) + scrollView.addSubview(imageView) + scrollView.contentSize = sourceImage.size + + cancelButton.setTitle("取消", for: .normal) + confirmButton.setTitle("确认", for: .normal) + [cancelButton, confirmButton].forEach { + $0.setTitleColor(.white, for: .normal) + $0.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + } + cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + view.addSubview(scrollView) + view.addSubview(cancelButton) + view.addSubview(confirmButton) + scrollView.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.centerY.equalToSuperview() + make.height.equalTo(scrollView.snp.width) + } + cancelButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(20) + make.top.equalTo(view.safeAreaLayoutGuide).offset(10) + make.width.height.greaterThanOrEqualTo(44) + } + confirmButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(20) + make.top.equalTo(view.safeAreaLayoutGuide).offset(10) + make.width.height.greaterThanOrEqualTo(44) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard !didConfigureZoom, scrollView.bounds.width > 0 else { return } + didConfigureZoom = true + let minimumZoom = max( + scrollView.bounds.width / sourceImage.size.width, + scrollView.bounds.height / sourceImage.size.height + ) + scrollView.minimumZoomScale = minimumZoom + scrollView.maximumZoomScale = max(minimumZoom * 6, 6) + scrollView.zoomScale = minimumZoom + let contentWidth = sourceImage.size.width * minimumZoom + let contentHeight = sourceImage.size.height * minimumZoom + scrollView.contentOffset = CGPoint( + x: max(0, (contentWidth - scrollView.bounds.width) / 2), + y: max(0, (contentHeight - scrollView.bounds.height) / 2) + ) + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView } + + @objc private func cancelTapped() { dismiss(animated: true) } + + @objc private func confirmTapped() { + guard let data = renderedCropData() else { return } + dismiss(animated: true) { [onConfirm] in onConfirm(data) } + } + + func renderedCropData() -> Data? { + let cropRectInImageView = scrollView.convert(scrollView.bounds, to: imageView) + let scaleX = sourceImage.size.width / imageView.bounds.width + let scaleY = sourceImage.size.height / imageView.bounds.height + var cropRect = CGRect( + x: cropRectInImageView.minX * scaleX, + y: cropRectInImageView.minY * scaleY, + width: cropRectInImageView.width * scaleX, + height: cropRectInImageView.height * scaleY + ).integral + cropRect = cropRect.intersection(CGRect(origin: .zero, size: sourceImage.size)) + guard !cropRect.isEmpty, let cgImage = sourceImage.cgImage?.cropping(to: cropRect) else { return nil } + let cropped = UIImage(cgImage: cgImage) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 480, height: 480)) + return renderer.image { _ in + cropped.draw(in: CGRect(x: 0, y: 0, width: 480, height: 480)) + }.jpegData(compressionQuality: 0.9) + } + + private static func normalized(_ image: UIImage) -> UIImage { + guard image.imageOrientation != .up else { return image } + let renderer = UIGraphicsImageRenderer(size: image.size) + return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: image.size)) } + } +} diff --git a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift index 7a2d37f..e12f648 100644 --- a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift +++ b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift @@ -13,7 +13,6 @@ final class ProfileSpaceSettingsViewController: BaseViewController { private let viewModel: ProfileSpaceSettingsViewModel private let profileAPI: ProfileAPI - private let ossUploadService: OSSUploadService private let scrollView = UIScrollView() private let contentStack = UIStackView() @@ -21,17 +20,22 @@ final class ProfileSpaceSettingsViewController: BaseViewController { private let saveButton = AppButton(title: "保存") private let avatarImageView = UIImageView() + private let avatarChangeOverlay = UIView() + private let avatarChangeLabel = UILabel() private let realNameLabel = UILabel() private let nicknameField = UITextField() private let editProfileButton = UIButton(type: .system) private let introductionTextView = UITextView() private let introductionPlaceholderLabel = UILabel() private let certificationWrap = ProfileTagWrapView() + private let certificationExpandButton = UIButton(type: .system) private let attrWrap = ProfileTagWrapView() private let shootWrap = ProfileTagWrapView() private let attrField = UITextField() private let shootField = UITextField() + private let attrAddButton = UIButton(type: .system) + private let shootAddButton = UIButton(type: .system) private let businessStartButton = ProfileTimeButton() private let businessEndButton = ProfileTimeButton() @@ -39,7 +43,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController { private let holidayEndButton = ProfileTimeButton() private let deviceWrap = ProfileTagWrapView() - private let addDeviceButton = UIButton(type: .system) + private let addDeviceButton = ProfileDashedBorderButton(type: .system) private var statusButtons: [UIButton] = [] private let dateStack = UIStackView() @@ -49,12 +53,10 @@ final class ProfileSpaceSettingsViewController: BaseViewController { init( viewModel: ProfileSpaceSettingsViewModel = ProfileSpaceSettingsViewModel(), - profileAPI: ProfileAPI = NetworkServices.shared.profileAPI, - ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService + profileAPI: ProfileAPI = NetworkServices.shared.profileAPI ) { self.viewModel = viewModel self.profileAPI = profileAPI - self.ossUploadService = ossUploadService super.init(nibName: nil, bundle: nil) } @@ -65,6 +67,17 @@ final class ProfileSpaceSettingsViewController: BaseViewController { override func setupNavigationBar() { title = "个人空间配置" + navigationItem.backButtonDisplayMode = .minimal + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + ProfileNavigationStyle.apply(to: navigationController, showsDivider: false) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + ProfileNavigationStyle.restore(navigationController) } override func setupUI() { @@ -82,6 +95,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController { bottomBar.backgroundColor = .white saveButton.isEnabled = false + saveButton.snp.updateConstraints { make in make.height.equalTo(48) } configureProfileCard() configureLabelCard() @@ -96,7 +110,9 @@ final class ProfileSpaceSettingsViewController: BaseViewController { make.leading.trailing.bottom.equalToSuperview() } saveButton.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 15, bottom: 16, right: 15)) + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(15) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() @@ -117,8 +133,11 @@ final class ProfileSpaceSettingsViewController: BaseViewController { } saveButton.addTarget(self, action: #selector(saveTapped), for: .touchUpInside) editProfileButton.addTarget(self, action: #selector(editProfileTapped), for: .touchUpInside) + certificationExpandButton.addTarget(self, action: #selector(certificationExpandTapped), for: .touchUpInside) addDeviceButton.addTarget(self, action: #selector(addDeviceTapped), for: .touchUpInside) addScheduleButton.addTarget(self, action: #selector(addScheduleTapped), for: .touchUpInside) + attrField.addTarget(self, action: #selector(labelInputChanged), for: .editingChanged) + shootField.addTarget(self, action: #selector(labelInputChanged), for: .editingChanged) [businessStartButton, businessEndButton, holidayStartButton, holidayEndButton].forEach { $0.addTarget(self, action: #selector(timeButtonTapped(_:)), for: .touchUpInside) } @@ -146,17 +165,23 @@ final class ProfileSpaceSettingsViewController: BaseViewController { nicknameField.isEnabled = viewModel.isEditingProfile introductionTextView.isEditable = viewModel.isEditingProfile introductionTextView.text = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? "暂无简介" : viewModel.introduction - introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x565656) + introductionTextView.textColor = viewModel.introduction.isEmpty && !viewModel.isEditingProfile ? UIColor(hex: 0xB6BECA) : UIColor(hex: 0x4B5563) updateIntroductionPlaceholder() - editProfileButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "square.and.pencil"), for: .normal) - certificationWrap.setItems(viewModel.scenicCertification, style: .certification, removable: false) + editProfileButton.setImage(ProfileSpaceIcon.edit, for: .normal) + avatarChangeOverlay.isHidden = !viewModel.isEditingProfile + let visibleCertifications = viewModel.isCertificationExpanded + ? viewModel.scenicCertification + : Array(viewModel.scenicCertification.prefix(3)) + certificationWrap.setItems(visibleCertifications, style: .certification, removable: false) + certificationExpandButton.isHidden = viewModel.scenicCertification.count <= 3 + certificationExpandButton.setTitle(viewModel.isCertificationExpanded ? "收起" : "展开", for: .normal) attrWrap.setItems(viewModel.attrLabels, style: .label, removable: true) { [weak self] label in - self?.confirmDelete(title: "删除确认", message: "确认删除标签“\(label)”吗?") { + self?.confirmDelete(title: "删除标签", message: "您确定要删除\(label)标签吗?") { self?.viewModel.removeAttrLabel(label) } } shootWrap.setItems(viewModel.shootLabels, style: .label, removable: true) { [weak self] label in - self?.confirmDelete(title: "删除确认", message: "确认删除拍摄说明“\(label)”吗?") { + self?.confirmDelete(title: "删除拍摄说明", message: "您确定要删除\(label)拍摄说明吗?") { self?.viewModel.removeShootLabel(label) } } @@ -165,12 +190,15 @@ final class ProfileSpaceSettingsViewController: BaseViewController { holidayStartButton.setTitle(viewModel.holidayStartTime ?? "00:00", for: .normal) holidayEndButton.setTitle(viewModel.holidayEndTime ?? "00:00", for: .normal) deviceWrap.setItems(viewModel.cameraDevices, style: .device, removable: true) { [weak self] device in - self?.confirmDelete(title: "删除确认", message: "确认删除设备“\(device)”吗?") { + self?.confirmDelete(title: "删除设备", message: "您确定要删除\(device)设备吗?") { self?.viewModel.removeDevice(device) } } addDeviceButton.isHidden = viewModel.cameraDevices.count > 10 + deviceWrap.invalidateIntrinsicContentSize() + deviceWrap.setNeedsLayout() updateStatusButtons() + updateLabelAddButtons() rebuildDateButtons() rebuildSchedules() } @@ -184,20 +212,34 @@ final class ProfileSpaceSettingsViewController: BaseViewController { avatarImageView.layer.cornerRadius = 58 avatarImageView.clipsToBounds = true - avatarImageView.backgroundColor = AppColor.inputBackground + avatarImageView.backgroundColor = AppColor.primaryLight avatarImageView.contentMode = .scaleAspectFill - avatarImageView.image = UIImage(systemName: "person.crop.circle.fill") + avatarImageView.image = UIImage(named: "profile_avatar_default") avatarImageView.tintColor = UIColor(hex: 0xB6BECA) + avatarImageView.accessibilityLabel = "头像" avatarImageView.snp.makeConstraints { make in make.width.height.equalTo(116) } + avatarImageView.addSubview(avatarChangeOverlay) + avatarChangeOverlay.backgroundColor = UIColor.black.withAlphaComponent(0.35) + avatarChangeOverlay.isHidden = true + avatarChangeOverlay.addSubview(avatarChangeLabel) + avatarChangeLabel.text = "点击更换" + avatarChangeLabel.textColor = .white + avatarChangeLabel.font = .systemFont(ofSize: 12, weight: .medium) + avatarChangeOverlay.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + avatarChangeLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } let textStack = UIStackView() textStack.axis = .vertical - textStack.spacing = 8 + textStack.spacing = 0 realNameLabel.font = .systemFont(ofSize: 14) - realNameLabel.textColor = UIColor(hex: 0x565656) + realNameLabel.textColor = UIColor(hex: 0x4B5563) let nicknameRow = UIStackView() nicknameRow.axis = .horizontal @@ -211,8 +253,9 @@ final class ProfileSpaceSettingsViewController: BaseViewController { nicknameField.delegate = self nicknameField.addTarget(self, action: #selector(nicknameChanged), for: .editingChanged) editProfileButton.tintColor = AppColor.primary + editProfileButton.accessibilityLabel = "编辑个人信息" editProfileButton.snp.makeConstraints { make in - make.width.height.equalTo(28) + make.width.height.equalTo(44) } nicknameRow.addArrangedSubview(nicknameField) nicknameRow.addArrangedSubview(editProfileButton) @@ -236,8 +279,18 @@ final class ProfileSpaceSettingsViewController: BaseViewController { textStack.addArrangedSubview(realNameLabel) textStack.addArrangedSubview(nicknameRow) + textStack.setCustomSpacing(8, after: nicknameRow) textStack.addArrangedSubview(introductionTextView) + textStack.setCustomSpacing(4, after: introductionTextView) textStack.addArrangedSubview(certificationWrap) + certificationExpandButton.setTitleColor(AppColor.primary, for: .normal) + certificationExpandButton.titleLabel?.font = .systemFont(ofSize: 14) + certificationExpandButton.contentHorizontalAlignment = .left + certificationExpandButton.isHidden = true + certificationExpandButton.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(20) + } + textStack.addArrangedSubview(certificationExpandButton) row.addArrangedSubview(avatarImageView) row.addArrangedSubview(textStack) @@ -247,11 +300,30 @@ final class ProfileSpaceSettingsViewController: BaseViewController { private func configureLabelCard() { let card = makeCard() - card.addArrangedSubview(makeSectionTitle("标签")) + card.spacing = 0 + let title = makeSectionTitle("标签") + card.addArrangedSubview(title) + card.setCustomSpacing(12, after: title) card.addArrangedSubview(attrWrap) - card.addArrangedSubview(makeAddRow(field: attrField, placeholder: "添加标签", action: #selector(addAttrTapped))) + card.setCustomSpacing(8, after: attrWrap) + let attrRow = makeAddRow( + field: attrField, + button: attrAddButton, + placeholder: "添加标签", + action: #selector(addAttrTapped) + ) + card.addArrangedSubview(attrRow) + card.setCustomSpacing(20, after: attrRow) card.addArrangedSubview(shootWrap) - card.addArrangedSubview(makeAddRow(field: shootField, placeholder: "添加拍摄说明", action: #selector(addShootTapped))) + card.setCustomSpacing(12, after: shootWrap) + let shootRow = makeAddRow( + field: shootField, + button: shootAddButton, + placeholder: "添加拍摄说明", + action: #selector(addShootTapped) + ) + card.addArrangedSubview(shootRow) + card.setCustomSpacing(8, after: shootRow) let hint = UILabel() hint.text = "每个标签最多20个字符,最多8个标签" hint.font = .systemFont(ofSize: 12) @@ -262,8 +334,11 @@ final class ProfileSpaceSettingsViewController: BaseViewController { private func configureBusinessHoursCard() { let card = makeCard() - card.addArrangedSubview(makeSectionTitle("营业时间")) - card.addArrangedSubview(makeTimeRow(title: "工作日营业时间", start: businessStartButton, end: businessEndButton)) + let title = makeSectionTitle("营业时间") + let workdayRow = makeTimeRow(title: "工作日营业时间", start: businessStartButton, end: businessEndButton) + card.addArrangedSubview(title) + card.addArrangedSubview(workdayRow) + card.setCustomSpacing(20, after: workdayRow) card.addArrangedSubview(makeTimeRow(title: "法定节假日营业时间", start: holidayStartButton, end: holidayEndButton)) contentStack.addArrangedSubview(card) } @@ -272,16 +347,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController { let card = makeCard() card.addArrangedSubview(makeSectionTitle("相机设备")) card.addArrangedSubview(deviceWrap) - addDeviceButton.setTitle("+ 添加设备", for: .normal) + addDeviceButton.setTitle("+ 添加设备", for: .normal) addDeviceButton.setTitleColor(AppColor.primary, for: .normal) addDeviceButton.titleLabel?.font = .systemFont(ofSize: 14) - addDeviceButton.layer.borderColor = AppColor.primary.cgColor - addDeviceButton.layer.borderWidth = 1 addDeviceButton.layer.cornerRadius = 4 - addDeviceButton.snp.makeConstraints { make in - make.height.equalTo(32) - } - card.addArrangedSubview(addDeviceButton) + addDeviceButton.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + addDeviceButton.contentHorizontalAlignment = .left + addDeviceButton.snp.makeConstraints { make in make.height.equalTo(28) } + deviceWrap.setTrailingView(addDeviceButton) contentStack.addArrangedSubview(card) } @@ -335,6 +408,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController { header.addArrangedSubview(UIView()) header.addArrangedSubview(addScheduleButton) card.addArrangedSubview(header) + card.setCustomSpacing(8, after: header) scheduleStack.axis = .vertical scheduleStack.spacing = 12 @@ -358,11 +432,18 @@ final class ProfileSpaceSettingsViewController: BaseViewController { let label = UILabel() label.text = title label.font = .systemFont(ofSize: 14, weight: .medium) - label.textColor = UIColor(hex: 0x252525) + label.textColor = ["相机设备", "接单状态", "日程安排"].contains(title) + ? UIColor(hex: 0x4B5563) + : UIColor(hex: 0x252525) return label } - private func makeAddRow(field: UITextField, placeholder: String, action: Selector) -> UIView { + private func makeAddRow( + field: UITextField, + button: UIButton, + placeholder: String, + action: Selector + ) -> UIView { let row = UIStackView() row.axis = .horizontal row.spacing = 8 @@ -379,13 +460,14 @@ final class ProfileSpaceSettingsViewController: BaseViewController { field.snp.makeConstraints { make in make.height.equalTo(36) } - let button = UIButton(type: .system) button.setTitle("添加标签", for: .normal) button.setTitleColor(.white, for: .normal) + button.setTitleColor(.white, for: .disabled) button.backgroundColor = AppColor.primary button.titleLabel?.font = .systemFont(ofSize: 14) button.layer.cornerRadius = 4 button.addTarget(self, action: action, for: .touchUpInside) + button.isEnabled = false button.snp.makeConstraints { make in make.width.equalTo(80) make.height.equalTo(36) @@ -403,12 +485,12 @@ final class ProfileSpaceSettingsViewController: BaseViewController { let titleLabel = UILabel() titleLabel.text = title titleLabel.font = .systemFont(ofSize: 14) - titleLabel.textColor = UIColor(hex: 0x565656) + titleLabel.textColor = UIColor(hex: 0x4B5563) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) let toLabel = UILabel() toLabel.text = "至" toLabel.font = .systemFont(ofSize: 14) - toLabel.textColor = UIColor(hex: 0x565656) + toLabel.textColor = UIColor(hex: 0x4B5563) let spacer = UIView() row.addArrangedSubview(titleLabel) row.addArrangedSubview(spacer) @@ -417,7 +499,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController { row.addArrangedSubview(end) [start, end].forEach { button in button.snp.makeConstraints { make in - make.width.equalTo(70) + make.width.equalTo(60) make.height.equalTo(36) } } @@ -428,8 +510,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController { let trimmed = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !trimmed.isEmpty else { avatarImageView.kf.cancelDownloadTask() - avatarImageView.backgroundColor = AppColor.inputBackground - avatarImageView.image = UIImage(systemName: "person.crop.circle.fill") + avatarImageView.backgroundColor = AppColor.primaryLight + avatarImageView.image = UIImage(named: "profile_avatar_default") avatarImageView.tintColor = UIColor(hex: 0xB6BECA) avatarImageView.contentMode = .scaleAspectFit return @@ -440,6 +522,26 @@ final class ProfileSpaceSettingsViewController: BaseViewController { private func updateIntroductionPlaceholder() { introductionPlaceholderLabel.isHidden = !viewModel.isEditingProfile || !introductionTextView.text.isEmpty + introductionTextView.layer.borderWidth = viewModel.isEditingProfile ? 1 : 0 + introductionTextView.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor + introductionTextView.layer.cornerRadius = 4 + introductionTextView.textContainerInset = viewModel.isEditingProfile + ? UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + : .zero + introductionPlaceholderLabel.snp.remakeConstraints { make in + make.top.equalToSuperview().offset(viewModel.isEditingProfile ? 8 : 0) + make.leading.equalToSuperview().offset(viewModel.isEditingProfile ? 8 : 0) + make.trailing.equalToSuperview().inset(viewModel.isEditingProfile ? 8 : 0) + } + } + + private func updateLabelAddButtons() { + let pairs = [(attrField, attrAddButton), (shootField, shootAddButton)] + for (field, button) in pairs { + let enabled = !(field.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + button.isEnabled = enabled + button.backgroundColor = enabled ? AppColor.primary : AppColor.buttonDisabled + } } private func updateStatusButtons() { @@ -447,7 +549,8 @@ final class ProfileSpaceSettingsViewController: BaseViewController { let selected = button.tag == viewModel.acceptOrderStatus button.backgroundColor = selected ? AppColor.primary : .white button.layer.borderColor = (selected ? AppColor.primary : UIColor(hex: 0xB6BECA)).cgColor - button.setTitleColor(selected ? .white : UIColor(hex: 0x565656), for: .normal) + button.setTitleColor(selected ? .white : UIColor(hex: 0x4B5563), for: .normal) + button.accessibilityTraits = selected ? [.button, .selected] : .button } } @@ -485,7 +588,7 @@ final class ProfileSpaceSettingsViewController: BaseViewController { for schedule in schedules { let item = ProfileScheduleItemView(schedule: schedule) item.onDelete = { [weak self] in - self?.confirmDelete(title: "删除确认", message: "确认删除该日程吗?") { + self?.confirmDelete(title: "删除日程", message: "您确定要删除该日程吗?") { guard let self else { return } Task { await self.viewModel.deleteSchedule(id: schedule.id, api: self.profileAPI) } } @@ -498,32 +601,20 @@ final class ProfileSpaceSettingsViewController: BaseViewController { } private func confirmDelete(title: String, message: String, onConfirm: @escaping () -> Void) { - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "确定", style: .destructive) { _ in onConfirm() }) - present(alert, animated: true) + let dialog = ProfileDeleteDialogViewController(title: title, message: message, onConfirm: onConfirm) + present(dialog, animated: false) } private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { - let alert = UIAlertController(title: "选择时间", message: "\n\n\n\n\n\n\n", preferredStyle: .actionSheet) - let picker = UIDatePicker() - picker.datePickerMode = .time - picker.preferredDatePickerStyle = .wheels - picker.locale = Locale(identifier: "zh_CN") - if let initialTime { - picker.date = date(fromTime: initialTime) - } - alert.view.addSubview(picker) - picker.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(16) - make.top.equalToSuperview().offset(42) - make.height.equalTo(160) - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in - onConfirm(self.timeString(from: picker.date)) - }) - present(alert, animated: true) + let dialog = ProfileTimePickerDialogViewController( + initialDate: date(fromTime: "00:00"), + onConfirm: { [weak self] date in + guard let self else { return } + onConfirm(timeString(from: date)) + } + ) + present(dialog, animated: false) + _ = initialTime } private func date(fromTime value: String) -> Date { @@ -564,11 +655,21 @@ final class ProfileSpaceSettingsViewController: BaseViewController { @objc private func addAttrTapped() { viewModel.addAttrLabel(attrField.text ?? "") attrField.text = "" + updateLabelAddButtons() } @objc private func addShootTapped() { viewModel.addShootLabel(shootField.text ?? "") shootField.text = "" + updateLabelAddButtons() + } + + @objc private func labelInputChanged() { + updateLabelAddButtons() + } + + @objc private func certificationExpandTapped() { + viewModel.toggleCertificationExpanded() } @objc private func timeButtonTapped(_ sender: ProfileTimeButton) { @@ -584,17 +685,10 @@ final class ProfileSpaceSettingsViewController: BaseViewController { } @objc private func addDeviceTapped() { - let alert = UIAlertController(title: "添加设备", message: nil, preferredStyle: .alert) - alert.addTextField { field in - field.placeholder = "请输入设备名称" - field.returnKeyType = .done - field.delegate = self + let dialog = ProfileDeviceDialogViewController { [weak self] value in + self?.viewModel.addDevice(value) } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in - self?.viewModel.addDevice(alert.textFields?.first?.text ?? "") - }) - present(alert, animated: true) + present(dialog, animated: false) } @objc private func statusTapped(_ sender: UIButton) { @@ -632,6 +726,17 @@ extension ProfileSpaceSettingsViewController: UITextFieldDelegate, UITextViewDel return true } + func textField( + _ textField: UITextField, + shouldChangeCharactersIn range: NSRange, + replacementString string: String + ) -> Bool { + guard textField === attrField || textField === shootField else { return true } + let current = textField.text ?? "" + guard let swiftRange = Range(range, in: current) else { return false } + return current.replacingCharacters(in: swiftRange, with: string).count <= 20 + } + func textViewDidChange(_ textView: UITextView) { viewModel.updateIntroduction(textView.text) updateIntroductionPlaceholder() @@ -654,17 +759,19 @@ extension ProfileSpaceSettingsViewController: PHPickerViewControllerDelegate { guard let provider = results.first?.itemProvider else { return } Task { do { - let data = try await ProfileAvatarPickerLoader.loadImageData(from: provider) - let scenicId = AppStore.shared.currentScenicId - let fileURL = try await ossUploadService.uploadUserAvatar( - data: data, - fileName: "avatar_\(UUID().uuidString).jpg", - scenicId: scenicId - ) { _ in } - try await profileAPI.updateUserInfo(nickname: nil, password: nil, avatar: fileURL) + let image = try await ProfileAvatarPickerLoader.loadImage(from: provider) await MainActor.run { - viewModel.updateAvatarURL(fileURL) - showToast("修改成功") + let crop = ProfileAvatarCropViewController(image: image) { [weak self] data in + guard let self else { return } + Task { + await self.viewModel.uploadAvatar( + data: data, + fileName: "avatar_\(UUID().uuidString).jpg", + api: self.profileAPI + ) + } + } + self.present(crop, animated: true) } } catch { await MainActor.run { showToast(error.localizedDescription) } @@ -690,6 +797,7 @@ final class ProfileAddScheduleViewController: BaseViewController { private let remarkTextView = UITextView() private let remarkPlaceholderLabel = UILabel() private let countLabel = UILabel() + private var selectedOrderNumber: String? init(selectedDate: Date, viewModel: ProfileSpaceSettingsViewModel, profileAPI: ProfileAPI) { self.selectedDate = selectedDate @@ -707,11 +815,24 @@ final class ProfileAddScheduleViewController: BaseViewController { title = "添加日程" } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + ProfileNavigationStyle.apply(to: navigationController, showsDivider: true) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + ProfileNavigationStyle.restore(navigationController) + } + override func setupUI() { view.addSubview(contentStack) view.addSubview(bottomBar) bottomBar.addSubview(saveButton) bottomBar.backgroundColor = .white + view.backgroundColor = AppColor.pageBackground + saveButton.isEnabled = false + saveButton.snp.updateConstraints { make in make.height.equalTo(48) } contentStack.axis = .vertical contentStack.spacing = 20 @@ -736,7 +857,9 @@ final class ProfileAddScheduleViewController: BaseViewController { make.leading.trailing.bottom.equalToSuperview() } saveButton.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 15, bottom: 16, right: 15)) + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(15) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } contentStack.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(16) @@ -759,6 +882,7 @@ final class ProfileAddScheduleViewController: BaseViewController { endButton.addTarget(self, action: #selector(endTapped), for: .touchUpInside) orderButton.addTarget(self, action: #selector(orderTapped), for: .touchUpInside) remarkTextView.delegate = self + nameField.addTarget(self, action: #selector(formValueChanged), for: .editingChanged) } private func makeTitle(_ title: String) -> UILabel { @@ -789,15 +913,15 @@ final class ProfileAddScheduleViewController: BaseViewController { let title = UILabel() title.text = "时间" title.font = .systemFont(ofSize: 14) - title.textColor = UIColor(hex: 0x565656) + title.textColor = UIColor(hex: 0x4B5563) let toLabel = UILabel() toLabel.text = "至" toLabel.font = .systemFont(ofSize: 14) - toLabel.textColor = UIColor(hex: 0x565656) + toLabel.textColor = UIColor(hex: 0x4B5563) [startButton, endButton].forEach { button in button.setTitle("请选择", for: .normal) button.snp.makeConstraints { make in - make.width.equalTo(78) + make.width.equalTo(60) make.height.equalTo(36) } } @@ -818,17 +942,25 @@ final class ProfileAddScheduleViewController: BaseViewController { orderButton.layer.borderWidth = 1 orderButton.layer.cornerRadius = 8 orderButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + var configuration = UIButton.Configuration.plain() + configuration.title = "关联订单 (可选)" + configuration.image = UIImage(systemName: "chevron.down") + configuration.imagePlacement = .trailing + configuration.imagePadding = 8 + configuration.baseForegroundColor = .black + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + orderButton.configuration = configuration } private func configureRemarkView() { remarkTextView.font = .systemFont(ofSize: 14) - remarkTextView.textColor = UIColor(hex: 0x565656) + remarkTextView.textColor = UIColor(hex: 0x4B5563) remarkTextView.layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor remarkTextView.layer.borderWidth = 1 remarkTextView.layer.cornerRadius = 8 remarkTextView.textContainerInset = UIEdgeInsets(top: 10, left: 12, bottom: 10, right: 12) remarkTextView.textContainer.lineFragmentPadding = 0 - remarkPlaceholderLabel.text = "请输入备注信息" + remarkPlaceholderLabel.text = "请输入日程内容" remarkPlaceholderLabel.font = remarkTextView.font remarkPlaceholderLabel.textColor = UIColor(hex: 0xB6BECA) remarkTextView.addSubview(remarkPlaceholderLabel) @@ -844,25 +976,14 @@ final class ProfileAddScheduleViewController: BaseViewController { } private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { - let alert = UIAlertController(title: "选择时间", message: "\n\n\n\n\n\n\n", preferredStyle: .actionSheet) - let picker = UIDatePicker() - picker.datePickerMode = .time - picker.preferredDatePickerStyle = .wheels - picker.locale = Locale(identifier: "zh_CN") - alert.view.addSubview(picker) - picker.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(16) - make.top.equalToSuperview().offset(42) - make.height.equalTo(160) - } - alert.addAction(UIAlertAction(title: "取消", style: .cancel)) - alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in + let date = Self.date(from: "00:00") + let dialog = ProfileTimePickerDialogViewController(initialDate: date) { selectedDate in let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "HH:mm" - onConfirm(formatter.string(from: picker.date)) - }) - present(alert, animated: true) + onConfirm(formatter.string(from: selectedDate)) + } + present(dialog, animated: false) _ = initialTime } @@ -873,6 +994,7 @@ final class ProfileAddScheduleViewController: BaseViewController { self?.endButton.setTitle("请选择", for: .normal) } self?.startButton.setTitle(value, for: .normal) + self?.updateSaveEnabled() } } @@ -883,11 +1005,17 @@ final class ProfileAddScheduleViewController: BaseViewController { return } self?.endButton.setTitle(value, for: .normal) + self?.updateSaveEnabled() } } @objc private func orderTapped() { - showToast("关联订单 (可选)") + let controller = TaskOrderSelectViewController(initialOrderNumber: selectedOrderNumber) + controller.onConfirmed = { [weak self] orderNumber in + self?.selectedOrderNumber = orderNumber + self?.applySelectedOrder() + } + navigationController?.pushViewController(controller, animated: true) } @objc private func saveTapped() { @@ -901,7 +1029,7 @@ final class ProfileAddScheduleViewController: BaseViewController { startTime: start, endTime: end, scheduleDate: selectedDate, - orderNumber: nil, + orderNumber: selectedOrderNumber, api: profileAPI ) if success { @@ -909,6 +1037,34 @@ final class ProfileAddScheduleViewController: BaseViewController { } } } + + @objc private func formValueChanged() { + updateSaveEnabled() + } + + private func updateSaveEnabled() { + let name = (nameField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let start = startButton.title(for: .normal) + let end = endButton.title(for: .normal) + saveButton.isEnabled = !name.isEmpty + && start != nil && start != "请选择" + && end != nil && end != "请选择" + && ProfileSpaceSettingsViewModel.isStart(start ?? "", before: end ?? "") + } + + private func applySelectedOrder() { + var configuration = orderButton.configuration ?? .plain() + configuration.title = selectedOrderNumber ?? "关联订单 (可选)" + configuration.baseForegroundColor = .black + orderButton.configuration = configuration + } + + private static func date(from time: String?) -> Date { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + return formatter.date(from: time ?? "00:00") ?? Date() + } } extension ProfileAddScheduleViewController: UITextFieldDelegate, UITextViewDelegate { @@ -941,35 +1097,69 @@ private final class ProfileTimeButton: UIButton { override init(frame: CGRect) { super.init(frame: frame) titleLabel?.font = .systemFont(ofSize: 14) - setTitleColor(AppColor.textPrimary, for: .normal) - backgroundColor = .white - layer.cornerRadius = 8 - layer.borderColor = AppColor.border.cgColor - layer.borderWidth = 1 + titleLabel?.lineBreakMode = .byClipping + setTitleColor(UIColor(hex: 0x4B5563), for: .normal) + backgroundColor = AppColor.inputBackground + layer.cornerRadius = 4 + contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + contentHorizontalAlignment = .center } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds.insetBy(dx: -4, dy: -4).contains(point) + } } /// 个人空间配置日期按钮。 private final class ProfileDateButton: UIButton { var date: Date? - func configure(weekday: String, day: String, selected: Bool) { - let title = "\(weekday)\n\(day)" - setTitle(title, for: .normal) - titleLabel?.numberOfLines = 2 - titleLabel?.textAlignment = .center - titleLabel?.font = .systemFont(ofSize: 14) - setTitleColor(selected ? .white : UIColor(hex: 0x565656), for: .normal) - backgroundColor = selected ? AppColor.primary : AppColor.inputBackground - layer.cornerRadius = 4 - snp.makeConstraints { make in - make.height.equalTo(58) + private let weekdayLabel = UILabel() + private let dayContainer = UIView() + private let dayLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + weekdayLabel.font = .systemFont(ofSize: 14) + weekdayLabel.textAlignment = .center + weekdayLabel.textColor = UIColor(hex: 0x4B5563) + dayContainer.layer.cornerRadius = 4 + dayContainer.isUserInteractionEnabled = false + dayLabel.font = .systemFont(ofSize: 14) + dayLabel.textAlignment = .center + dayContainer.addSubview(dayLabel) + addSubview(weekdayLabel) + addSubview(dayContainer) + weekdayLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(18) } + dayContainer.snp.makeConstraints { make in + make.top.equalTo(weekdayLabel.snp.bottom).offset(12) + make.centerX.equalToSuperview() + make.width.height.equalTo(36) + make.bottom.equalToSuperview() + } + dayLabel.snp.makeConstraints { make in make.center.equalToSuperview() } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(weekday: String, day: String, selected: Bool) { + weekdayLabel.text = weekday + dayLabel.text = day + dayLabel.textColor = selected ? .white : UIColor(hex: 0x4B5563) + dayContainer.backgroundColor = selected ? AppColor.primary : AppColor.inputBackground + accessibilityLabel = "星期\(weekday),\(day)日" + accessibilityTraits = selected ? [.button, .selected] : .button } } @@ -983,6 +1173,17 @@ private final class ProfileTagWrapView: UIView { private var itemViews: [UIView] = [] private var removeHandler: ((String) -> Void)? + private weak var trailingView: UIView? + + func setTrailingView(_ view: UIView) { + trailingView = view + if view.superview !== self { + addSubview(view) + } + itemViews.append(view) + invalidateIntrinsicContentSize() + setNeedsLayout() + } func setItems(_ items: [String], style: Style, removable: Bool, onRemove: ((String) -> Void)? = nil) { subviews.forEach { $0.removeFromSuperview() } @@ -991,6 +1192,10 @@ private final class ProfileTagWrapView: UIView { addSubview(view) return view } + if let trailingView { + addSubview(trailingView) + itemViews.append(trailingView) + } removeHandler = onRemove invalidateIntrinsicContentSize() setNeedsLayout() @@ -1011,6 +1216,7 @@ private final class ProfileTagWrapView: UIView { var y: CGFloat = 0 var rowHeight: CGFloat = 0 for view in itemViews { + guard !view.isHidden else { continue } let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) if x > 0, x + size.width > width { x = 0 @@ -1030,6 +1236,7 @@ private final class ProfileTagWrapView: UIView { var height: CGFloat = 0 var rowHeight: CGFloat = 0 for view in itemViews { + guard !view.isHidden else { continue } let size = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) if x > 0, x + size.width > width { x = 0 @@ -1045,8 +1252,8 @@ private final class ProfileTagWrapView: UIView { private func makeItem(title: String, style: Style, removable: Bool) -> UIView { let button = UIButton(type: .system) button.setTitle(removable ? "\(title) ×" : title, for: .normal) - button.titleLabel?.font = .systemFont(ofSize: style == .certification ? 12 : 14) - button.contentEdgeInsets = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + button.titleLabel?.font = .systemFont(ofSize: style == .device ? 14 : 12) + button.contentEdgeInsets = UIEdgeInsets(top: 2, left: style == .device ? 8 : 4, bottom: 2, right: style == .device ? 8 : 4) button.layer.cornerRadius = 4 switch style { case .label: @@ -1057,7 +1264,7 @@ private final class ProfileTagWrapView: UIView { button.setTitleColor(AppColor.warning, for: .normal) case .device: button.backgroundColor = AppColor.inputBackground - button.setTitleColor(UIColor(hex: 0x565656), for: .normal) + button.setTitleColor(UIColor(hex: 0x4B5563), for: .normal) } if removable { button.addAction(UIAction { [weak self] _ in @@ -1100,11 +1307,11 @@ private final class ProfileScheduleItemView: UIStackView { let title = UILabel() title.text = "\(schedule.startTime) - \(schedule.endTime) \(schedule.name)" title.font = .systemFont(ofSize: 14) - title.textColor = UIColor(hex: 0x565656) + title.textColor = UIColor(hex: 0x4B5563) title.numberOfLines = 1 let delete = UIButton(type: .system) delete.setTitle("删除", for: .normal) - delete.setTitleColor(AppColor.danger, for: .normal) + delete.setTitleColor(UIColor(hex: 0xFF5656), for: .normal) delete.titleLabel?.font = .systemFont(ofSize: 14) delete.addAction(UIAction { [weak self] _ in self?.onDelete?() }, for: .touchUpInside) top.addArrangedSubview(title) @@ -1136,7 +1343,7 @@ private final class ProfileScheduleItemView: UIStackView { let label = UILabel() label.text = text label.font = .systemFont(ofSize: 14) - label.textColor = UIColor(hex: 0x565656) + label.textColor = UIColor(hex: 0x4B5563) label.numberOfLines = 1 return label } @@ -1151,7 +1358,7 @@ private final class ProfileScheduleItemView: UIStackView { /// 头像选择器数据加载器。 private enum ProfileAvatarPickerLoader { - static func loadImageData(from provider: NSItemProvider) async throws -> Data { + static func loadImage(from provider: NSItemProvider) async throws -> UIImage { try await withCheckedThrowingContinuation { continuation in guard provider.canLoadObject(ofClass: UIImage.self) else { continuation.resume(throwing: OSSUploadError.unsupportedFileType) @@ -1162,11 +1369,11 @@ private enum ProfileAvatarPickerLoader { continuation.resume(throwing: error) return } - guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { + guard let image = object as? UIImage else { continuation.resume(throwing: OSSUploadError.emptyFile) return } - continuation.resume(returning: data) + continuation.resume(returning: image) } } } diff --git a/suixinkan/UI/Profile/RealNameAuthViewController.swift b/suixinkan/UI/Profile/RealNameAuthViewController.swift index a0d6024..11727b3 100644 --- a/suixinkan/UI/Profile/RealNameAuthViewController.swift +++ b/suixinkan/UI/Profile/RealNameAuthViewController.swift @@ -197,7 +197,7 @@ final class RealNameAuthViewController: BaseViewController { } private func submit() async { - let scenicId = AppStore.shared.currentScenicId + let scenicId = AppStore.shared.session.currentScenicId guard scenicId > 0 else { showToast("当前景区信息缺失") return diff --git a/suixinkan/UI/Profile/WithdrawalSettingsViewController.swift b/suixinkan/UI/Profile/WithdrawalSettingsViewController.swift index 591a73a..66a3d19 100644 --- a/suixinkan/UI/Profile/WithdrawalSettingsViewController.swift +++ b/suixinkan/UI/Profile/WithdrawalSettingsViewController.swift @@ -221,7 +221,7 @@ final class WithdrawalSettingsViewController: BaseViewController { } private func submit() async { - let scenicId = AppStore.shared.currentScenicId + let scenicId = AppStore.shared.session.currentScenicId guard scenicId > 0 else { showToast("当前景区信息缺失") return diff --git a/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift b/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift index 4e79365..a634518 100644 --- a/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift +++ b/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift @@ -302,7 +302,7 @@ final class ScenicApplicationViewController: BaseViewController { } private func startUploadIfNeeded() async { - let scenicId = AppStore.shared.currentScenicId + let scenicId = AppStore.shared.session.currentScenicId await viewModel.processPendingUploads(uploader: ossUploadService, scenicId: scenicId) } } diff --git a/suixinkan/UI/Task/TaskAddViewController.swift b/suixinkan/UI/Task/TaskAddViewController.swift index 0cb17ed..57316e2 100644 --- a/suixinkan/UI/Task/TaskAddViewController.swift +++ b/suixinkan/UI/Task/TaskAddViewController.swift @@ -307,7 +307,7 @@ final class TaskAddViewController: BaseViewController { } private func uploadPickedResults(_ results: [PHPickerResult], isImage: Bool) { - let scenicId = AppStore.shared.currentScenicId + let scenicId = AppStore.shared.session.currentScenicId guard scenicId > 0 else { showToast("请先选择景区") return @@ -344,7 +344,7 @@ final class TaskAddViewController: BaseViewController { } private func uploadCapturedPayload(_ payload: TaskMediaLoader.Payload, fileType: Int) { - let scenicId = AppStore.shared.currentScenicId + let scenicId = AppStore.shared.session.currentScenicId guard scenicId > 0 else { showToast("请先选择景区") return diff --git a/suixinkan/UI/Task/TaskOrderSelectViewController.swift b/suixinkan/UI/Task/TaskOrderSelectViewController.swift index f7d47ad..54da178 100644 --- a/suixinkan/UI/Task/TaskOrderSelectViewController.swift +++ b/suixinkan/UI/Task/TaskOrderSelectViewController.swift @@ -33,12 +33,23 @@ final class TaskOrderSelectViewController: BaseViewController { title = "订单列表" } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + ProfileNavigationStyle.apply(to: navigationController, showsDivider: false) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + ProfileNavigationStyle.restore(navigationController) + } + override func setupUI() { tableView.backgroundColor = AppColor.pageBackground tableView.separatorStyle = .none tableView.register(TaskOrderCell.self, forCellReuseIdentifier: TaskOrderCell.reuseIdentifier) tableView.dataSource = self tableView.delegate = self + tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 0, right: 0) emptyLabel.text = "无有效订单" emptyLabel.font = .app(.body) @@ -51,6 +62,7 @@ final class TaskOrderSelectViewController: BaseViewController { view.addSubview(emptyLabel) view.addSubview(bottomBar) bottomBar.addSubview(confirmButton) + confirmButton.snp.updateConstraints { make in make.height.equalTo(48) } } override func setupConstraints() { @@ -58,7 +70,9 @@ final class TaskOrderSelectViewController: BaseViewController { make.leading.trailing.bottom.equalToSuperview() } confirmButton.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(AppSpacing.md) + make.top.equalToSuperview().offset(16) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) } tableView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) @@ -135,6 +149,8 @@ private final class TaskOrderCell: UITableViewCell { private let statusRow = TaskInfoRowView(label: "订单状态:") private let numberRow = TaskInfoRowView(label: "订单编号:") private let payTimeRow = TaskInfoRowView(label: "付款时间:") + private let userIDRow = TaskInfoRowView(label: "用户 UID:") + private let phoneRow = TaskInfoRowView(label: "用户手机号:") override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -152,6 +168,8 @@ private final class TaskOrderCell: UITableViewCell { cardView.addSubview(statusRow) cardView.addSubview(numberRow) cardView.addSubview(payTimeRow) + cardView.addSubview(userIDRow) + cardView.addSubview(phoneRow) cardView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)) @@ -169,6 +187,14 @@ private final class TaskOrderCell: UITableViewCell { } payTimeRow.snp.makeConstraints { make in make.top.equalTo(numberRow.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalTo(projectRow) + } + userIDRow.snp.makeConstraints { make in + make.top.equalTo(payTimeRow.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalTo(projectRow) + } + phoneRow.snp.makeConstraints { make in + make.top.equalTo(userIDRow.snp.bottom).offset(AppSpacing.xs) make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.md) } } @@ -183,8 +209,12 @@ private final class TaskOrderCell: UITableViewCell { statusRow.apply(value: order.orderStatusLabel.isEmpty ? "--" : order.orderStatusLabel) numberRow.apply(value: order.orderNumber) payTimeRow.apply(value: order.payTime.isEmpty ? "--" : order.payTime) - cardView.layer.borderColor = isSelected ? AppColor.danger.cgColor : AppColor.border.cgColor + userIDRow.apply(value: "U\(order.userId)") + phoneRow.apply(value: order.userPhone) + cardView.layer.borderColor = isSelected ? UIColor(hex: 0xFF0000).cgColor : AppColor.border.cgColor cardView.layer.borderWidth = isSelected ? 2 : 1 + accessibilityLabel = "订单\(order.orderNumber)" + accessibilityTraits = isSelected ? [.button, .selected] : .button } } @@ -197,17 +227,15 @@ private final class TaskInfoRowView: UIView { super.init(frame: .zero) labelView.text = label labelView.font = .app(.body) - labelView.textColor = AppColor.textSecondary + labelView.textColor = AppColor.textPrimary valueView.font = .app(.body) valueView.textColor = AppColor.textPrimary valueView.numberOfLines = 0 addSubview(labelView) addSubview(valueView) - labelView.snp.makeConstraints { make in - make.leading.top.bottom.equalToSuperview() - make.width.equalTo(80) - } + labelView.setContentCompressionResistancePriority(.required, for: .horizontal) + labelView.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview() } valueView.snp.makeConstraints { make in make.leading.equalTo(labelView.snp.trailing).offset(AppSpacing.xs) make.trailing.top.bottom.equalToSuperview() diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index 136df3b..15e952a 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -164,7 +164,7 @@ final class WildPhotographerReportListViewController: BaseViewController { } private var currentScenicId: Int? { - let scenicId = AppStore.shared.currentScenicId + let scenicId = AppStore.shared.session.currentScenicId return scenicId > 0 ? scenicId : nil } } diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift index 170f1d3..7a0aa34 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -556,8 +556,8 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { await viewModel.submitReport( api: api, uploader: uploader, - scenicId: AppStore.shared.currentScenicId, - scenicName: AppStore.shared.currentScenicName + scenicId: AppStore.shared.session.currentScenicId, + scenicName: AppStore.shared.session.currentScenicName ) } } diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift index c4e9c25..642152c 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift @@ -34,8 +34,8 @@ final class WildReportRiskMapViewController: BaseViewController { init( api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, locationProvider: any LocationProviding = LocationProvider.shared, - scenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }, - scenicNameProvider: @escaping () -> String = { AppStore.shared.currentScenicName } + scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, + scenicNameProvider: @escaping () -> String = { AppStore.shared.session.currentScenicName } ) { self.viewModel = WildReportRiskMapViewModel() self.api = api diff --git a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift index e7341a0..584596a 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift @@ -343,7 +343,7 @@ final class WildReportSupplementEvidenceViewController: BaseViewController { await viewModel.submit( api: api, uploader: uploader, - scenicId: AppStore.shared.currentScenicId + scenicId: AppStore.shared.session.currentScenicId ) } } diff --git a/suixinkanTests/AMapBootstrapTests.swift b/suixinkanTests/AMapBootstrapTests.swift index 7b1c8c0..be53336 100644 --- a/suixinkanTests/AMapBootstrapTests.swift +++ b/suixinkanTests/AMapBootstrapTests.swift @@ -26,14 +26,14 @@ final class AMapBootstrapTests: XCTestCase { } func testConfigureReturnsFalseWhenPrivacyNotAccepted() { - appStore.privacyAgreementAccepted = false + appStore.session.privacyAgreementAccepted = false XCTAssertFalse(AMapBootstrap.configureIfNeeded(appStore: appStore)) XCTAssertFalse(AMapBootstrap.isConfigured) } func testConfigureIsIdempotentWhenPrivacyAccepted() { - appStore.privacyAgreementAccepted = true + appStore.session.privacyAgreementAccepted = true XCTAssertTrue(AMapBootstrap.configureIfNeeded(appStore: appStore)) XCTAssertTrue(AMapBootstrap.isConfigured) @@ -42,7 +42,7 @@ final class AMapBootstrapTests: XCTestCase { } func testRequireConfiguredThrowsWhenPrivacyNotAccepted() { - appStore.privacyAgreementAccepted = false + appStore.session.privacyAgreementAccepted = false XCTAssertThrowsError(try AMapBootstrap.requireConfigured(appStore: appStore)) { error in XCTAssertEqual(error as? LocationProviderError, .privacyNotAccepted) diff --git a/suixinkanTests/APIClientTests.swift b/suixinkanTests/APIClientTests.swift index 54acb07..979ba97 100644 --- a/suixinkanTests/APIClientTests.swift +++ b/suixinkanTests/APIClientTests.swift @@ -96,4 +96,59 @@ final class APIClientTests: XCTestCase { await fulfillment(of: [expectation], timeout: 1) } + + func testMultipartUploadBuildsAndroidCompatibleRequest() async throws { + let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())]) + let client = APIClient( + environment: .testing, + session: session, + appVersion: "9.0.0", + osType: "iOS" + ) + client.bindAuthTokenProvider { "avatar-token" } + let bytes = Data([0x01, 0x02, 0xFE, 0xFF]) + + let _: EmptyPayload = try await client.sendMultipart( + path: "/api/yf-handset-app/photog/userinfo-update-avatar", + fileName: "avatar.jpg", + mimeType: "image/jpeg", + data: bytes + ) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.timeoutInterval, 60) + XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "avatar-token") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-APP-VERSION"), "9.0.0") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-OS-TYPE"), "iOS") + XCTAssertTrue(request.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("multipart/form-data; boundary=") == true) + let body = try XCTUnwrap(request.httpBody) + let bodyText = String(decoding: body, as: UTF8.self) + XCTAssertTrue(bodyText.contains("name=\"file\"; filename=\"avatar.jpg\"")) + XCTAssertTrue(bodyText.contains("Content-Type: image/jpeg")) + XCTAssertNotNil(body.range(of: bytes)) + } + + func testMultipartUploadMapsBusinessFailure() async { + let session = MockURLSession(responses: [try! TestJSON.errorEnvelope(code: 100001, msg: "头像上传失败")]) + let client = APIClient(environment: .testing, session: session) + + do { + let _: EmptyPayload = try await client.sendMultipart( + path: "/api/yf-handset-app/photog/userinfo-update-avatar", + fileName: "avatar.jpg", + mimeType: "image/jpeg", + data: Data([1]) + ) + XCTFail("业务失败时应抛出 serverCode") + } catch let error as APIError { + guard case let .serverCode(code, message) = error else { + return XCTFail("期望 serverCode,实际为 \(error)") + } + XCTAssertEqual(code, 100001) + XCTAssertEqual(message, "头像上传失败") + } catch { + XCTFail("期望 APIError,实际为 \(error)") + } + } } diff --git a/suixinkanTests/AllFunctionsViewModelTests.swift b/suixinkanTests/AllFunctionsViewModelTests.swift index 34a4438..8214b17 100644 --- a/suixinkanTests/AllFunctionsViewModelTests.swift +++ b/suixinkanTests/AllFunctionsViewModelTests.swift @@ -19,9 +19,9 @@ final class AllFunctionsViewModelTests: XCTestCase { defaults.removePersistentDomain(forName: "AllFunctionsViewModelTests") appStore = AppStore(defaults: defaults) menuStore = HomeCommonMenuStore(defaults: defaults) - appStore.userId = "1001" - appStore.accountType = "scenic_user" - appStore.roleCode = AppRoleCode.photographer.rawValue + appStore.session.userId = "1001" + appStore.session.accountType = .scenicUser + appStore.session.roleCode = AppRoleCode.photographer.rawValue } override func tearDown() { @@ -30,7 +30,7 @@ final class AllFunctionsViewModelTests: XCTestCase { } func testLoadFunctionsUsesDefaultFirstFourWhenNoSavedURIs() { - appStore.savePermissionItems([ + appStore.permissions.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), HomePermissionItem(id: "3", name: "任务", uri: "task_management"), @@ -47,23 +47,23 @@ final class AllFunctionsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["message_center"]) XCTAssertEqual( menuStore.savedCommonURIs( - accountScope: appStore.accountCachePrefix, - roleCode: appStore.roleCode + accountScope: appStore.session.accountCachePrefix, + roleCode: appStore.session.roleCode ), ["wallet", "cloud_management", "task_management", "system_settings"] ) } func testLoadFunctionsUsesSavedURIsOverDefault() { - appStore.savePermissionItems([ + appStore.permissions.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), HomePermissionItem(id: "3", name: "任务", uri: "task_management"), ]) menuStore.saveCommonURIs( ["cloud_management"], - accountScope: appStore.accountCachePrefix, - roleCode: appStore.roleCode + accountScope: appStore.session.accountCachePrefix, + roleCode: appStore.session.roleCode ) let viewModel = AllFunctionsViewModel(appStore: appStore, commonMenuStore: menuStore) @@ -74,14 +74,14 @@ final class AllFunctionsViewModelTests: XCTestCase { } func testAddToCommonMovesMenuAndPersists() { - appStore.savePermissionItems([ + appStore.permissions.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), ]) menuStore.saveCommonURIs( ["wallet"], - accountScope: appStore.accountCachePrefix, - roleCode: appStore.roleCode + accountScope: appStore.session.accountCachePrefix, + roleCode: appStore.session.roleCode ) let viewModel = AllFunctionsViewModel(appStore: appStore, commonMenuStore: menuStore) viewModel.loadFunctions() @@ -94,22 +94,22 @@ final class AllFunctionsViewModelTests: XCTestCase { XCTAssertTrue(viewModel.didCustomize) XCTAssertEqual( menuStore.savedCommonURIs( - accountScope: appStore.accountCachePrefix, - roleCode: appStore.roleCode + accountScope: appStore.session.accountCachePrefix, + roleCode: appStore.session.roleCode ), ["wallet", "cloud_management"] ) } func testRemoveFromCommonMovesMenuAndPersists() { - appStore.savePermissionItems([ + appStore.permissions.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), ]) menuStore.saveCommonURIs( ["wallet", "cloud_management"], - accountScope: appStore.accountCachePrefix, - roleCode: appStore.roleCode + accountScope: appStore.session.accountCachePrefix, + roleCode: appStore.session.roleCode ) let viewModel = AllFunctionsViewModel(appStore: appStore, commonMenuStore: menuStore) viewModel.loadFunctions() @@ -123,14 +123,14 @@ final class AllFunctionsViewModelTests: XCTestCase { } func testPhotographerReportMenuVisibleAndCanBeAddedToCommon() { - appStore.savePermissionItems([ + appStore.permissions.savePermissionItems([ HomePermissionItem(id: "1", name: "位置上报", uri: "location_report"), HomePermissionItem(id: "2", name: "举报摄影师", uri: "report_photographer"), ]) menuStore.saveCommonURIs( ["location_report"], - accountScope: appStore.accountCachePrefix, - roleCode: appStore.roleCode + accountScope: appStore.session.accountCachePrefix, + roleCode: appStore.session.roleCode ) let viewModel = AllFunctionsViewModel(appStore: appStore, commonMenuStore: menuStore) diff --git a/suixinkanTests/AppStoreModuleTests.swift b/suixinkanTests/AppStoreModuleTests.swift new file mode 100644 index 0000000..98e3d85 --- /dev/null +++ b/suixinkanTests/AppStoreModuleTests.swift @@ -0,0 +1,134 @@ +// +// AppStoreModuleTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 验证 AppStore 子模块的持久化、账号隔离与统一清理行为。 +final class AppStoreModuleTests: XCTestCase { + + private var suiteName: String! + private var defaults: UserDefaults! + private var appStore: AppStore! + + override func setUp() { + super.setUp() + suiteName = "AppStoreModuleTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + appStore = AppStore(defaults: defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + appStore = nil + defaults = nil + suiteName = nil + super.tearDown() + } + + func testAccountTypeKnownAndUnknownRawValueRoundTrips() { + XCTAssertEqual(AppAccountType(rawValue: "scenic_user"), .scenicUser) + XCTAssertEqual(AppAccountType(rawValue: "store_user"), .storeUser) + XCTAssertEqual(AppAccountType(rawValue: "future_account"), .unknown("future_account")) + XCTAssertEqual(AppAccountType.scenicUser.rawValue, "scenic_user") + XCTAssertEqual(AppAccountType.storeUser.rawValue, "store_user") + XCTAssertEqual(AppAccountType.unknown("future_account").rawValue, "future_account") + } + + func testSessionUsesExistingKeysAndPreservesUnknownAccountType() { + defaults.set("legacy-token", forKey: "key_in_token") + defaults.set("future_account", forKey: "key_in_account_type") + + XCTAssertEqual(appStore.session.token, "legacy-token") + XCTAssertEqual(appStore.session.accountType, .unknown("future_account")) + + appStore.session.userId = "1001" + appStore.session.currentScenicId = 22 + + XCTAssertEqual(defaults.string(forKey: "key_in_user_id"), "1001") + XCTAssertEqual(defaults.string(forKey: "key_in_current_scenic_id"), "22") + XCTAssertEqual(appStore.session.accountCachePrefix, "1001_future_account") + } + + func testFeatureStoresAreIsolatedByAccountAndDecodeSnapshots() { + selectAccount(userId: "1001", type: .scenicUser) + let permissions = [HomePermissionItem(id: "1", name: "钱包", uri: "wallet")] + let scenicList = [ScenicInfo(id: 22, name: "测试景区", status: 1)] + var queueSettings = ScenicQueueSettingsSnapshot() + queueSettings.shootMinute = 8 + + appStore.permissions.savePermissionItems(permissions) + appStore.permissions.saveRoleScenicList(scenicList) + appStore.location.onlineStatus = true + appStore.location.lastReportTime = 123_456 + appStore.location.reminderMinutes = 10 + appStore.payment.isOpenReceiveVoice = true + appStore.scenicQueue.punchSpotId = "33" + appStore.scenicQueue.punchSpotName = "东门" + appStore.scenicQueue.saveSettingsSnapshot(queueSettings) + appStore.scenicQueue.savePresetVoices([" A ", "", "B"]) + + selectAccount(userId: "2002", type: .storeUser) + XCTAssertTrue(appStore.permissions.permissionItems().isEmpty) + XCTAssertTrue(appStore.permissions.roleScenicList().isEmpty) + XCTAssertFalse(appStore.location.onlineStatus) + XCTAssertEqual(appStore.location.lastReportTime, 0) + XCTAssertEqual(appStore.location.reminderMinutes, 0) + XCTAssertFalse(appStore.payment.isOpenReceiveVoice) + XCTAssertEqual(appStore.scenicQueue.punchSpotId, "") + XCTAssertNil(appStore.scenicQueue.settingsSnapshot()) + + selectAccount(userId: "1001", type: .scenicUser) + XCTAssertEqual(appStore.permissions.permissionItems(), permissions) + XCTAssertEqual(appStore.permissions.roleScenicList(), scenicList) + XCTAssertTrue(appStore.location.onlineStatus) + XCTAssertEqual(appStore.location.lastReportTime, 123_456) + XCTAssertEqual(appStore.location.reminderMinutes, 10) + XCTAssertTrue(appStore.payment.isOpenReceiveVoice) + XCTAssertTrue(appStore.scenicQueue.hasPunchSpotSaved()) + XCTAssertEqual(appStore.scenicQueue.punchSpotName, "东门") + XCTAssertEqual(appStore.scenicQueue.settingsSnapshot(), queueSettings) + XCTAssertEqual(appStore.scenicQueue.presetVoices(), ["A", "B"]) + } + + func testLogoutClearsCurrentAccountModulesAndPreservesLoginPreferences() { + appStore.session.lastLoginUsername = "13800000000" + appStore.session.privacyAgreementAccepted = true + + selectAccount(userId: "other", type: .storeUser) + appStore.scenicQueue.punchSpotId = "99" + + selectAccount(userId: "current", type: .scenicUser) + appStore.session.token = "token" + appStore.permissions.legacyRoleId = 41 + appStore.permissions.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")]) + appStore.location.onlineStatus = true + appStore.payment.isOpenReceiveVoice = true + appStore.scenicQueue.punchSpotId = "33" + + appStore.logout() + + XCTAssertFalse(appStore.session.isLoggedIn) + XCTAssertEqual(appStore.session.userId, "") + XCTAssertEqual(appStore.session.lastLoginUsername, "13800000000") + XCTAssertTrue(appStore.session.privacyAgreementAccepted) + + selectAccount(userId: "current", type: .scenicUser) + XCTAssertEqual(appStore.permissions.legacyRoleId, 0) + XCTAssertTrue(appStore.permissions.permissionItems().isEmpty) + XCTAssertFalse(appStore.location.onlineStatus) + XCTAssertFalse(appStore.payment.isOpenReceiveVoice) + XCTAssertEqual(appStore.scenicQueue.punchSpotId, "") + + selectAccount(userId: "other", type: .storeUser) + XCTAssertEqual(appStore.scenicQueue.punchSpotId, "99") + } + + private func selectAccount(userId: String, type: AppAccountType) { + appStore.session.userId = userId + appStore.session.accountType = type + } +} diff --git a/suixinkanTests/CooperationAcquirerViewModelTests.swift b/suixinkanTests/CooperationAcquirerViewModelTests.swift index 95e5373..7f3304b 100644 --- a/suixinkanTests/CooperationAcquirerViewModelTests.swift +++ b/suixinkanTests/CooperationAcquirerViewModelTests.swift @@ -16,7 +16,7 @@ final class CooperationAcquirerViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "CooperationAcquirerViewModelTests")! defaults.removePersistentDomain(forName: "CooperationAcquirerViewModelTests") appStore = AppStore(defaults: defaults) - appStore.currentStoreId = 5 + appStore.session.currentStoreId = 5 } func testSaveRemarkWithValidLengthCallsAPI() async throws { diff --git a/suixinkanTests/DepositOrderListViewModelTests.swift b/suixinkanTests/DepositOrderListViewModelTests.swift index b9613e8..52a2ca7 100644 --- a/suixinkanTests/DepositOrderListViewModelTests.swift +++ b/suixinkanTests/DepositOrderListViewModelTests.swift @@ -10,8 +10,8 @@ import XCTest final class DepositOrderListViewModelTests: XCTestCase { override func tearDown() { - AppStore.shared.currentStoreId = 0 - AppStore.shared.currentScenicId = 0 + AppStore.shared.session.currentStoreId = 0 + AppStore.shared.session.currentScenicId = 0 super.tearDown() } @@ -34,7 +34,7 @@ final class DepositOrderListViewModelTests: XCTestCase { } func testStoreIdZeroAbortsWithMessage() async { - AppStore.shared.currentStoreId = 0 + AppStore.shared.session.currentStoreId = 0 let viewModel = DepositOrderListViewModel() var message: String? viewModel.onShowMessage = { message = $0 } diff --git a/suixinkanTests/HomeLocationStateStoreTests.swift b/suixinkanTests/HomeLocationStateStoreTests.swift index 90a30c1..f54401d 100644 --- a/suixinkanTests/HomeLocationStateStoreTests.swift +++ b/suixinkanTests/HomeLocationStateStoreTests.swift @@ -16,8 +16,8 @@ final class HomeLocationStateStoreTests: XCTestCase { defaults = UserDefaults(suiteName: "HomeLocationStateStoreTests")! defaults.removePersistentDomain(forName: "HomeLocationStateStoreTests") appStore = AppStore(defaults: defaults) - appStore.userId = "1001" - appStore.accountType = "scenic_user" + appStore.session.userId = "1001" + appStore.session.accountType = .scenicUser } override func tearDown() { @@ -27,21 +27,21 @@ final class HomeLocationStateStoreTests: XCTestCase { func testExpiredOnlineSessionIsClearedOnRestore() { let expiredTimestamp = Int64(Date().timeIntervalSince1970 * 1000) - HomeLocationStateStore.onlineDurationMillis - 1_000 - appStore.onlineStatus = true - appStore.lastLocationReportTime = expiredTimestamp + appStore.location.onlineStatus = true + appStore.location.lastReportTime = expiredTimestamp let stateStore = HomeLocationStateStore(store: appStore) stateStore.restoreStateIfNeeded() XCTAssertFalse(stateStore.isOnline) - XCTAssertFalse(appStore.onlineStatus) - XCTAssertEqual(appStore.lastLocationReportTime, 0) + XCTAssertFalse(appStore.location.onlineStatus) + XCTAssertEqual(appStore.location.lastReportTime, 0) } func testActiveOnlineSessionRestoresCountdown() { let recentTimestamp = Int64(Date().timeIntervalSince1970 * 1000) - 60_000 - appStore.onlineStatus = true - appStore.lastLocationReportTime = recentTimestamp + appStore.location.onlineStatus = true + appStore.location.lastReportTime = recentTimestamp let stateStore = HomeLocationStateStore(store: appStore) stateStore.restoreStateIfNeeded() @@ -56,7 +56,7 @@ final class HomeLocationStateStoreTests: XCTestCase { } func testShouldTriggerTimeoutReminderWithinWindow() { - appStore.locationReminderMinutes = 10 + appStore.location.reminderMinutes = 10 let stateStore = HomeLocationStateStore(store: appStore) stateStore.startLocationReport(at: Int64(Date().timeIntervalSince1970 * 1000)) stateStore.updateReminderMinutes(10) @@ -124,22 +124,22 @@ final class HomeLocationStateStoreTests: XCTestCase { } func testRestoreClearsOnlineStateWhenStoredLastReportTimeIsMissing() { - appStore.onlineStatus = true - appStore.lastLocationReportTime = 0 + appStore.location.onlineStatus = true + appStore.location.lastReportTime = 0 let stateStore = HomeLocationStateStore(store: appStore) stateStore.restoreStateIfNeeded() XCTAssertFalse(stateStore.isOnline) - XCTAssertFalse(appStore.onlineStatus) - XCTAssertEqual(appStore.lastLocationReportTime, 0) + XCTAssertFalse(appStore.location.onlineStatus) + XCTAssertEqual(appStore.location.lastReportTime, 0) XCTAssertEqual(stateStore.nextReportCountdownSeconds, 0) } func testRestoreClearsRuntimeStateWhenPersistedOffline() { let stateStore = HomeLocationStateStore(store: appStore) stateStore.startLocationReport(at: Int64(Date().timeIntervalSince1970 * 1000)) - appStore.onlineStatus = false + appStore.location.onlineStatus = false stateStore.restoreStateIfNeeded() diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift index e76962b..cc2596d 100644 --- a/suixinkanTests/HomeViewModelTests.swift +++ b/suixinkanTests/HomeViewModelTests.swift @@ -18,12 +18,12 @@ final class HomeViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "HomeViewModelTests")! defaults.removePersistentDomain(forName: "HomeViewModelTests") appStore = AppStore(defaults: defaults) - appStore.userId = "2001" - appStore.accountType = "scenic_user" - appStore.roleCode = AppRoleCode.photographer.rawValue - appStore.roleName = "摄影师" - appStore.currentScenicId = 10 - appStore.currentScenicName = "测试景区" + appStore.session.userId = "2001" + appStore.session.accountType = .scenicUser + appStore.session.roleCode = AppRoleCode.photographer.rawValue + appStore.session.roleName = "摄影师" + appStore.session.currentScenicId = 10 + appStore.session.currentScenicName = "测试景区" } override func tearDown() { @@ -32,12 +32,12 @@ final class HomeViewModelTests: XCTestCase { } func testMinimalTopRoleFlag() { - appStore.roleCode = AppRoleCode.storeAdmin.rawValue + appStore.session.roleCode = AppRoleCode.storeAdmin.rawValue let viewModel = HomeViewModel(appStore: appStore) viewModel.refreshLocalDisplayState() XCTAssertTrue(viewModel.isMinimalTopRole) - appStore.roleCode = AppRoleCode.photographer.rawValue + appStore.session.roleCode = AppRoleCode.photographer.rawValue viewModel.refreshLocalDisplayState() XCTAssertFalse(viewModel.isMinimalTopRole) } @@ -77,7 +77,7 @@ final class HomeViewModelTests: XCTestCase { } func testRebuildCommonMenusFiltersUnknownURIs() { - appStore.savePermissionItems([ + appStore.permissions.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "2", name: "未知", uri: "unknown_uri"), ]) @@ -88,7 +88,7 @@ final class HomeViewModelTests: XCTestCase { func testRebuildCommonMenusPersistsDefaultURIsOnFirstLoad() { let menuStore = HomeCommonMenuStore(defaults: defaults) - appStore.savePermissionItems([ + appStore.permissions.savePermissionItems([ HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), HomePermissionItem(id: "3", name: "任务", uri: "task_management"), @@ -104,8 +104,8 @@ final class HomeViewModelTests: XCTestCase { ]) XCTAssertEqual( menuStore.savedCommonURIs( - accountScope: appStore.accountCachePrefix, - roleCode: appStore.roleCode + accountScope: appStore.session.accountCachePrefix, + roleCode: appStore.session.roleCode ), ["wallet", "cloud_management", "task_management", "system_settings"] ) @@ -124,7 +124,7 @@ final class HomeViewModelTests: XCTestCase { } func testRolePermissionBuildsCommonMenusAndStoreCardRole() async throws { - appStore.roleCode = AppRoleCode.storeAdmin.rawValue + appStore.session.roleCode = AppRoleCode.storeAdmin.rawValue let permissionResponse = RolePermissionResponse( role: RoleInfo( id: 46, @@ -170,7 +170,7 @@ final class HomeViewModelTests: XCTestCase { ), scenic: [] ) - appStore.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")]) + appStore.permissions.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")]) let permissionJSON = try TestJSON.envelope(data: [permissionResponse]) let session = MockURLSession(responses: [permissionJSON]) @@ -185,26 +185,26 @@ final class HomeViewModelTests: XCTestCase { } func testRefreshLocalDisplayStateClearsWhenNameMissing() { - appStore.currentScenicId = 10 - appStore.currentScenicName = "" + appStore.session.currentScenicId = 10 + appStore.session.currentScenicName = "" let viewModel = HomeViewModel(appStore: appStore) viewModel.refreshLocalDisplayState() XCTAssertEqual(viewModel.currentScenicName, "") - XCTAssertEqual(appStore.currentScenicId, 0) - XCTAssertEqual(appStore.currentScenicName, "") + XCTAssertEqual(appStore.session.currentScenicId, 0) + XCTAssertEqual(appStore.session.currentScenicName, "") } func testRefreshLocalDisplayStateClearsWhenIdMissing() { - appStore.currentScenicId = 0 - appStore.currentScenicName = "残留名称" + appStore.session.currentScenicId = 0 + appStore.session.currentScenicName = "残留名称" let viewModel = HomeViewModel(appStore: appStore) viewModel.refreshLocalDisplayState() XCTAssertEqual(viewModel.currentScenicName, "") - XCTAssertEqual(appStore.currentScenicName, "") + XCTAssertEqual(appStore.session.currentScenicName, "") } private func statusActionContainer(in view: HomeQuickActionsView) throws -> UIView { diff --git a/suixinkanTests/LocationProviderTests.swift b/suixinkanTests/LocationProviderTests.swift index 2dbb38f..806bc31 100644 --- a/suixinkanTests/LocationProviderTests.swift +++ b/suixinkanTests/LocationProviderTests.swift @@ -23,7 +23,7 @@ final class LocationProviderTests: XCTestCase { func testScenicSelectionUsesInjectedProviderSnapshot() async { let mock = MockLocationProvider() mock.snapshot = HomeLocationSnapshot(latitude: 30.2, longitude: 120.1, address: "杭州") - appStore.saveRoleScenicList([ + appStore.permissions.saveRoleScenicList([ ScenicInfo(id: 1, name: "西湖", status: 1, location: ScenicLocationInfo(lng: 120.1, lat: 30.2, address: "杭州"), coverImg: nil), ]) diff --git a/suixinkanTests/LocationReportHistoryViewModelTests.swift b/suixinkanTests/LocationReportHistoryViewModelTests.swift index 1410813..5bdbc0c 100644 --- a/suixinkanTests/LocationReportHistoryViewModelTests.swift +++ b/suixinkanTests/LocationReportHistoryViewModelTests.swift @@ -17,7 +17,7 @@ final class LocationReportHistoryViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "LocationReportHistoryViewModelTests")! defaults.removePersistentDomain(forName: "LocationReportHistoryViewModelTests") appStore = AppStore(defaults: defaults) - appStore.userId = "staff-99" + appStore.session.userId = "staff-99" } override func tearDown() { diff --git a/suixinkanTests/LocationReportServiceTests.swift b/suixinkanTests/LocationReportServiceTests.swift index 7cbf251..03f5153 100644 --- a/suixinkanTests/LocationReportServiceTests.swift +++ b/suixinkanTests/LocationReportServiceTests.swift @@ -17,8 +17,8 @@ final class LocationReportServiceTests: XCTestCase { defaults = UserDefaults(suiteName: "LocationReportServiceTests")! defaults.removePersistentDomain(forName: "LocationReportServiceTests") appStore = AppStore(defaults: defaults) - appStore.userId = "staff-1" - appStore.currentScenicId = 100 + appStore.session.userId = "staff-1" + appStore.session.currentScenicId = 100 } override func tearDown() { @@ -122,7 +122,7 @@ final class LocationReportServiceTests: XCTestCase { } func testMissingScenicThrows() async { - appStore.currentScenicId = 0 + appStore.session.currentScenicId = 0 let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: []))) let stateStore = HomeLocationStateStore(store: appStore) let service = LocationReportService(appStore: appStore, locationStateStore: stateStore) diff --git a/suixinkanTests/MaterialManagementViewModelTests.swift b/suixinkanTests/MaterialManagementViewModelTests.swift index 6613225..edf3413 100644 --- a/suixinkanTests/MaterialManagementViewModelTests.swift +++ b/suixinkanTests/MaterialManagementViewModelTests.swift @@ -10,17 +10,27 @@ import XCTest /// 素材管理 ViewModel 测试。 @MainActor final class MaterialManagementViewModelTests: XCTestCase { + func testDetailCarouselLoopsAcrossCopiedBoundaryPages() { + XCTAssertEqual(MaterialCarouselIndexMapper.initialRenderedPage(mediaCount: 3), 1) + XCTAssertEqual(MaterialCarouselIndexMapper.normalizedRenderedPage(0, mediaCount: 3), 3) + XCTAssertEqual(MaterialCarouselIndexMapper.logicalPage(renderedPage: 3, mediaCount: 3), 2) + XCTAssertEqual(MaterialCarouselIndexMapper.normalizedRenderedPage(4, mediaCount: 3), 1) + XCTAssertEqual(MaterialCarouselIndexMapper.logicalPage(renderedPage: 1, mediaCount: 3), 0) + XCTAssertEqual(MaterialCarouselIndexMapper.initialRenderedPage(mediaCount: 1), 0) + } + func testListLoadsMoreSearchesAndMapsFilterStatus() async { let api = MockMaterialAPI() api.listResponses = [ MaterialListResponse(total: 12, list: (0 ..< 10).map { makeItem(id: $0) }, order: MaterialOrderInfo(totalNum: 1386, avgOrderAmount: "12", refundTotal: "3")), - MaterialListResponse(total: 12, list: [makeItem(id: 10), makeItem(id: 11)]), + MaterialListResponse(total: 12, list: [makeItem(id: 9), makeItem(id: 10), makeItem(id: 11)]), MaterialListResponse(total: 1, list: [makeItem(id: 99)]), ] let viewModel = MaterialListViewModel() await viewModel.loadInitial(api: api) await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api) + XCTAssertEqual(viewModel.items.map(\.id), Array(0 ... 11)) await viewModel.selectFilter(.pending, api: api) XCTAssertEqual(api.listCalls.map(\.status), [4, 4, 0]) @@ -118,43 +128,69 @@ final class MaterialManagementViewModelTests: XCTestCase { XCTAssertNil(api.lastUploadRequest?.id) } - func testCloudMediaImportBuildsMaterialRequestWithoutUploadingAgain() async { - let api = MockMaterialAPI() - api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1) + func testChangingMediaTypeClearsSelectedMedia() async { let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) - var didSucceed = false - viewModel.onSubmitSuccess = { didSucceed = true } + await viewModel.addLocalMedia( + [makeUploadItem(fileName: "image.jpg")], + uploader: MockMaterialUploader(urls: ["https://cdn/image.jpg"]) + ) - await viewModel.loadInitial(api: api) - viewModel.updateMaterialName("云盘素材") - viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点")) - viewModel.addCloudMediaFiles([ - CloudFile(id: 31, fileUrl: "https://cdn/cloud.jpg", coverUrl: "https://cdn/cover-thumb.jpg", name: "cloud.jpg", type: 2, fileSize: 88), - ]) - await viewModel.setCoverImage(makeUploadItem(fileName: "cover.jpg"), uploader: MockMaterialUploader(urls: ["https://cdn/cover.jpg"])) - await viewModel.submit(api: api) + viewModel.selectMediaType(.video) - XCTAssertTrue(didSucceed) - XCTAssertEqual(viewModel.importedCloudFileIDs, Set([31])) - XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.originalName, "cloud.jpg") - XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.ossUrl, "https://cdn/cloud.jpg") - XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.size, 88) + XCTAssertEqual(viewModel.mediaType, .video) + XCTAssertTrue(viewModel.uploadedMediaList.isEmpty) } - func testCloudMediaImportFiltersByCurrentMaterialTypeAndDeduplicates() { + func testMediaLimitsAndSequentialUploadMatchAndroid() async { let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) - var messages: [String] = [] - viewModel.onShowMessage = { messages.append($0) } + let imageItems = (0 ..< 11).map { makeUploadItem(fileName: "\($0).jpg") } + let uploader = MockMaterialUploader(urls: (0 ..< 11).map { "https://cdn/\($0).jpg" }) - viewModel.addCloudMediaFiles([ - CloudFile(id: 1, fileUrl: "https://cdn/video.mp4", name: "video.mp4", type: 1), - CloudFile(id: 2, fileUrl: "https://cdn/image.jpg", name: "image.jpg", type: 2), - CloudFile(id: 2, fileUrl: "https://cdn/image.jpg", name: "image.jpg", type: 2), - ]) + await viewModel.addLocalMedia(imageItems, uploader: uploader) - XCTAssertEqual(viewModel.uploadedMediaList.map(\.ossUrl), ["https://cdn/image.jpg"]) - XCTAssertEqual(viewModel.importedCloudFileIDs, Set([2])) - XCTAssertTrue(messages.isEmpty) + XCTAssertEqual(viewModel.uploadedMediaList.count, 9) + XCTAssertEqual(uploader.uploadedFileNames, (0 ..< 9).map { "\($0).jpg" }) + + viewModel.selectMediaType(.video) + let videoUploader = MockMaterialUploader(urls: ["https://cdn/a.mp4", "https://cdn/b.mp4"]) + await viewModel.addLocalMedia( + [makeUploadItem(fileName: "a.mp4"), makeUploadItem(fileName: "b.mp4")], + uploader: videoUploader + ) + + XCTAssertEqual(viewModel.uploadedMediaList.map(\.fileName), ["a.mp4"]) + XCTAssertEqual(videoUploader.uploadedFileNames, ["a.mp4"]) + } + + func testTagDeletionRequiresConfirmation() async { + let api = MockMaterialAPI() + let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) + viewModel.updateTagInput("snow-tag") + await viewModel.addTag(api: api) + let tag = try! XCTUnwrap(viewModel.tagList.first) + + viewModel.requestDeleteTag(tag) + XCTAssertEqual(viewModel.pendingDeleteTag, tag) + XCTAssertEqual(viewModel.tagList, [tag]) + + viewModel.cancelDeleteTag() + XCTAssertNil(viewModel.pendingDeleteTag) + XCTAssertEqual(viewModel.tagList, [tag]) + + viewModel.requestDeleteTag(tag) + viewModel.confirmDeleteTag() + XCTAssertNil(viewModel.pendingDeleteTag) + XCTAssertTrue(viewModel.tagList.isEmpty) + } + + func testInputLengthsAreCappedLikeAndroid() { + let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) + + viewModel.updateMaterialName(String(repeating: "名", count: 35)) + viewModel.updateTagInput(String(repeating: "标", count: 20)) + + XCTAssertEqual(viewModel.materialName.count, 30) + XCTAssertEqual(viewModel.tagInput.count, 15) } func testUploadDialogStaysHiddenAfterCompletionProgressCallback() async { @@ -261,6 +297,7 @@ private final class MockMaterialAPI: MaterialManagementServing { @MainActor private final class MockMaterialUploader: MaterialOSSUploading { private var urls: [String] + private(set) var uploadedFileNames: [String] = [] init(urls: [String]) { self.urls = urls @@ -273,6 +310,7 @@ private final class MockMaterialUploader: MaterialOSSUploading { scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String { + uploadedFileNames.append(fileName) onProgress(100) return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst() } diff --git a/suixinkanTests/OrderListViewModelTests.swift b/suixinkanTests/OrderListViewModelTests.swift index 8b7b77e..f8404ba 100644 --- a/suixinkanTests/OrderListViewModelTests.swift +++ b/suixinkanTests/OrderListViewModelTests.swift @@ -10,9 +10,9 @@ import XCTest final class OrderListViewModelTests: XCTestCase { override func tearDown() { - AppStore.shared.roleCode = "" - AppStore.shared.roleName = "" - AppStore.shared.currentScenicId = 0 + AppStore.shared.session.roleCode = "" + AppStore.shared.session.roleName = "" + AppStore.shared.session.currentScenicId = 0 super.tearDown() } @@ -37,12 +37,12 @@ final class OrderListViewModelTests: XCTestCase { } func testRoleRoutingPath() { - AppStore.shared.roleCode = AppRoleCode.scenicAdmin.rawValue + AppStore.shared.session.roleCode = AppRoleCode.scenicAdmin.rawValue let scenicVM = OrderListViewModel() XCTAssertEqual(scenicVM.listAPIPath, "/api/app/scenic-admin/order/list") XCTAssertTrue(scenicVM.isScenicAdminReadOnly) - AppStore.shared.roleCode = AppRoleCode.photographer.rawValue + AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue let photoVM = OrderListViewModel() XCTAssertEqual(photoVM.listAPIPath, "/api/yf-handset-app/photog/order/listv2") XCTAssertFalse(photoVM.isScenicAdminReadOnly) diff --git a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift index 9247aca..4d078a0 100644 --- a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift +++ b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift @@ -16,9 +16,9 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase { let defaults = UserDefaults(suiteName: "PaymentCollectionDetailsViewModelTests")! defaults.removePersistentDomain(forName: "PaymentCollectionDetailsViewModelTests") appStore = AppStore(defaults: defaults) - appStore.userId = "10001" - appStore.currentScenicId = 10 - appStore.currentScenicName = "测试景区" + appStore.session.userId = "10001" + appStore.session.currentScenicId = 10 + appStore.session.currentScenicName = "测试景区" } override func tearDown() { @@ -76,12 +76,12 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase { XCTAssertFalse(viewModel.isVoiceBroadcastOpen) viewModel.toggleReceiveVoice() - XCTAssertTrue(appStore.isOpenReceiveVoice) + XCTAssertTrue(appStore.payment.isOpenReceiveVoice) XCTAssertTrue(viewModel.isVoiceBroadcastOpen) } func testLoadPayCodeClearsQRWhenScenicMissing() async { - appStore.currentScenicId = 0 + appStore.session.currentScenicId = 0 let session = MockURLSession(responses: []) let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) diff --git a/suixinkanTests/PaymentCollectionRecordViewModelTests.swift b/suixinkanTests/PaymentCollectionRecordViewModelTests.swift index 7c1bb96..d06329a 100644 --- a/suixinkanTests/PaymentCollectionRecordViewModelTests.swift +++ b/suixinkanTests/PaymentCollectionRecordViewModelTests.swift @@ -54,7 +54,7 @@ final class PaymentCollectionRecordViewModelTests: XCTestCase { let defaults = UserDefaults(suiteName: "PaymentCollectionRecordViewModelTests")! defaults.removePersistentDomain(forName: "PaymentCollectionRecordViewModelTests") let appStore = AppStore(defaults: defaults) - appStore.currentScenicId = 10 + appStore.session.currentScenicId = 10 let recordJSON = """ {"code":100000,"msg":"success","data":{"analyse":[{"date":"2024-01-15","order_count":1,"order_amount_sum":"50.00"}],"list":[{"order_number":"NO001","user_phone":"13800138000","order_amount":"50.00","create_date":"2024-01-15","create_time":"10:00:00"}]}} diff --git a/suixinkanTests/PermissionApplyViewModelTests.swift b/suixinkanTests/PermissionApplyViewModelTests.swift index 851328c..6507f69 100644 --- a/suixinkanTests/PermissionApplyViewModelTests.swift +++ b/suixinkanTests/PermissionApplyViewModelTests.swift @@ -17,7 +17,7 @@ final class PermissionApplyViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "PermissionApplyViewModelTests")! defaults.removePersistentDomain(forName: "PermissionApplyViewModelTests") appStore = AppStore(defaults: defaults) - appStore.saveRolePermissionList([ + appStore.permissions.saveRolePermissionList([ RolePermissionResponse( role: RoleInfo(id: 10, name: "摄影师", roleCode: "PHOTOGRAPHER", notes: "拍摄"), scenic: [ScenicInfo(id: 100, name: "已有景区", status: 1)] diff --git a/suixinkanTests/ProfileAvatarCropTests.swift b/suixinkanTests/ProfileAvatarCropTests.swift new file mode 100644 index 0000000..7b68f88 --- /dev/null +++ b/suixinkanTests/ProfileAvatarCropTests.swift @@ -0,0 +1,47 @@ +// +// ProfileAvatarCropTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 空间设置头像裁剪输出测试。 +final class ProfileAvatarCropTests: XCTestCase { + func testCropOutputIs480PixelSquare() throws { + let source = UIGraphicsImageRenderer(size: CGSize(width: 800, height: 600)).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 800, height: 600)) + } + let controller = ProfileAvatarCropViewController(image: source) { _ in } + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + + let data = try XCTUnwrap(controller.renderedCropData()) + let image = try XCTUnwrap(UIImage(data: data)) + XCTAssertEqual(image.size.width * image.scale, 480) + XCTAssertEqual(image.size.height * image.scale, 480) + } + + func testCropNormalizesImageOrientation() throws { + let base = UIGraphicsImageRenderer(size: CGSize(width: 600, height: 800)).image { context in + UIColor.blue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 600, height: 800)) + } + let cgImage = try XCTUnwrap(base.cgImage) + let rotated = UIImage(cgImage: cgImage, scale: 1, orientation: .left) + let controller = ProfileAvatarCropViewController(image: rotated) { _ in } + controller.loadViewIfNeeded() + controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + controller.view.layoutIfNeeded() + + let data = try XCTUnwrap(controller.renderedCropData()) + let output = try XCTUnwrap(UIImage(data: data)) + XCTAssertEqual(output.imageOrientation, .up) + XCTAssertEqual(output.size.width * output.scale, 480) + XCTAssertEqual(output.size.height * output.scale, 480) + } +} diff --git a/suixinkanTests/ProfileEditViewModelTests.swift b/suixinkanTests/ProfileEditViewModelTests.swift index 1edbbf4..cb6dfe4 100644 --- a/suixinkanTests/ProfileEditViewModelTests.swift +++ b/suixinkanTests/ProfileEditViewModelTests.swift @@ -57,7 +57,7 @@ final class ProfileEditViewModelTests: XCTestCase { ) await fulfillment(of: [expectation], timeout: 1) - XCTAssertEqual(AppStore.shared.userName, "新昵称") + XCTAssertEqual(AppStore.shared.session.userName, "新昵称") } } diff --git a/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift index 8cf0b2c..d3b415a 100644 --- a/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift +++ b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift @@ -20,14 +20,14 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "ProfileSpaceSettingsViewModelTests")! defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests") appStore = AppStore(defaults: defaults) - appStore.currentScenicId = 11 + appStore.session.currentScenicId = 11 calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! fixedNow = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8))! } override func tearDown() { - AppStore.shared.currentScenicId = 0 + AppStore.shared.session.currentScenicId = 0 appStore.logout() defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests") super.tearDown() @@ -170,7 +170,8 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase { viewModel.updateHolidayEndTime("20:00") viewModel.updateBusinessEndTime("08:00") - XCTAssertThrowsError(try viewModel.makeSaveRequest()) + XCTAssertEqual(viewModel.businessEndTime, "18:00") + XCTAssertNoThrow(try viewModel.makeSaveRequest()) } func testHasChangesAndSaveRequestFieldsMatchAndroid() async throws { @@ -250,7 +251,7 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase { } func testSpaceSettingsRoutePushesProfileSpaceSettingsPage() { - AppStore.shared.currentScenicId = 11 + AppStore.shared.session.currentScenicId = 11 let root = UIViewController() let navigationController = UINavigationController(rootViewController: root) @@ -259,8 +260,26 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase { XCTAssertTrue(navigationController.topViewController is ProfileSpaceSettingsViewController) } + func testCertificationExpansionAndAvatarUploadRefresh() async { + let response = PhotographerProfileInfoResponse.loaded(certifications: ["景区1", "景区2", "景区3", "景区4"]) + let api = FakeProfileSpaceSettingsAPI(response: response) + let viewModel = makeViewModel() + await viewModel.load(api: api) + + XCTAssertFalse(viewModel.isCertificationExpanded) + viewModel.toggleCertificationExpanded() + XCTAssertTrue(viewModel.isCertificationExpanded) + + let avatarData = Data([1, 2, 3]) + await viewModel.uploadAvatar(data: avatarData, fileName: "avatar.jpg", api: api) + + XCTAssertEqual(api.uploadedAvatarData, avatarData) + XCTAssertEqual(api.uploadedAvatarFileName, "avatar.jpg") + XCTAssertEqual(viewModel.avatarURL, "https://example.com/new-avatar.jpg") + } + func testSystemSettingsRoutePushesSettingPage() { - AppStore.shared.currentScenicId = 11 + AppStore.shared.session.currentScenicId = 11 let tabBarController = MainTabBarController() tabBarController.loadViewIfNeeded() guard @@ -292,6 +311,8 @@ private final class FakeProfileSpaceSettingsAPI: ProfileSpaceSettingsAPI { var lastUpdateRequest: UpdatePhotographerProfileInfoRequest? var lastAddScheduleRequest: AddPhotographerScheduleRequest? var lastDeletedScheduleId: Int? + var uploadedAvatarData: Data? + var uploadedAvatarFileName: String? init(response: PhotographerProfileInfoResponse) { self.response = response @@ -335,10 +356,40 @@ private final class FakeProfileSpaceSettingsAPI: ProfileSpaceSettingsAPI { func deleteSchedule(id: Int) async throws { lastDeletedScheduleId = id } + + func uploadAvatar(data: Data, fileName: String) async throws { + uploadedAvatarData = data + uploadedAvatarFileName = fileName + response = PhotographerProfileInfoResponse( + id: response.id, + scenicId: response.scenicId, + photogUid: response.photogUid, + realName: response.realName, + nickname: response.nickname, + avatar: "https://example.com/new-avatar.jpg", + scenicCertification: response.scenicCertification, + description: response.description, + attrLabel: response.attrLabel, + shootLabel: response.shootLabel, + cameraDevice: response.cameraDevice, + businessStartTime: response.businessStartTime, + businessEndTime: response.businessEndTime, + holidayStartTime: response.holidayStartTime, + holidayEndTime: response.holidayEndTime, + businessRange: response.businessRange, + acceptOrderStatus: response.acceptOrderStatus, + schedule: response.schedule, + virtualPhone: response.virtualPhone + ) + } } private extension PhotographerProfileInfoResponse { static func loaded() -> PhotographerProfileInfoResponse { + loaded(certifications: ["西湖"]) + } + + static func loaded(certifications: [String]) -> PhotographerProfileInfoResponse { PhotographerProfileInfoResponse( id: 1, scenicId: 11, @@ -346,7 +397,7 @@ private extension PhotographerProfileInfoResponse { realName: "林摄影", nickname: "小林", avatar: "https://example.com/a.jpg", - scenicCertification: ["西湖"], + scenicCertification: certifications, description: "简介", attrLabel: ["航拍"], shootLabel: ["亲子"], diff --git a/suixinkanTests/ProfileViewModelTests.swift b/suixinkanTests/ProfileViewModelTests.swift index c507d18..4b58ca8 100644 --- a/suixinkanTests/ProfileViewModelTests.swift +++ b/suixinkanTests/ProfileViewModelTests.swift @@ -15,10 +15,10 @@ final class ProfileViewModelTests: XCTestCase { } func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() { - AppStore.shared.roleCode = AppRoleCode.photographer.rawValue + AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue let viewModel = ProfileViewModel() XCTAssertTrue(viewModel.showPhotographerFields) - AppStore.shared.roleCode = "" + AppStore.shared.session.roleCode = "" } func testMaskCardNumberShowsLastFourDigits() { @@ -45,24 +45,24 @@ final class ProfileViewModelTests: XCTestCase { } func testAccountTypeLabelForStoreAccount() { - AppStore.shared.accountType = V9StoreUser.accountTypeValue + AppStore.shared.session.accountType = .storeUser let viewModel = ProfileViewModel() XCTAssertEqual(viewModel.accountTypeLabel, "门店账号") XCTAssertTrue(viewModel.isStoreAccount) - AppStore.shared.accountType = "" + AppStore.shared.session.accountType = .unknown("") } func testAccountTypeLabelForScenicAccount() { - AppStore.shared.accountType = V9ScenicUser.accountTypeValue + AppStore.shared.session.accountType = .scenicUser let viewModel = ProfileViewModel() XCTAssertEqual(viewModel.accountTypeLabel, "景区账号") XCTAssertFalse(viewModel.isStoreAccount) - AppStore.shared.accountType = "" + AppStore.shared.session.accountType = .unknown("") } func testApplyLocalProfileUpdateSyncsNicknameAndAvatar() { - AppStore.shared.userName = "全局昵称" - AppStore.shared.avatar = "https://cdn.example.com/new.jpg" + AppStore.shared.session.userName = "全局昵称" + AppStore.shared.session.avatar = "https://cdn.example.com/new.jpg" let viewModel = ProfileViewModel() viewModel.applyLocalProfileUpdate() @@ -70,7 +70,7 @@ final class ProfileViewModelTests: XCTestCase { XCTAssertEqual(viewModel.displayNickname, "全局昵称") XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/new.jpg") - AppStore.shared.userName = "" - AppStore.shared.avatar = "" + AppStore.shared.session.userName = "" + AppStore.shared.session.avatar = "" } } diff --git a/suixinkanTests/ScenicQueueFeatureTests.swift b/suixinkanTests/ScenicQueueFeatureTests.swift index 284aa48..830ef37 100644 --- a/suixinkanTests/ScenicQueueFeatureTests.swift +++ b/suixinkanTests/ScenicQueueFeatureTests.swift @@ -118,9 +118,9 @@ final class ScenicQueueFeatureTests: XCTestCase { let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.settings")! defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.settings") let store = AppStore(defaults: defaults) - store.userId = "u1" - store.accountType = "photog" - store.currentScenicId = 11 + store.session.userId = "u1" + store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 let api = MockScenicQueueAPI() let tts = MockScenicQueueTTSManager() let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts) @@ -165,20 +165,20 @@ final class ScenicQueueFeatureTests: XCTestCase { XCTAssertEqual(request?.missCallRequeueOffset, 2) XCTAssertEqual(request?.autoCallNextCount, 3) XCTAssertEqual(request?.voiceBroadcasts.map(\.content), ["请准备拍摄", "第二条"]) - XCTAssertEqual(store.scenicQueuePunchSpotName, "东门打卡点") - XCTAssertEqual(store.scenicQueueSettingsSnapshot()?.shootMinute, 9) + XCTAssertEqual(store.scenicQueue.punchSpotName, "东门打卡点") + XCTAssertEqual(store.scenicQueue.settingsSnapshot()?.shootMinute, 9) } func testMainViewModelGatePagingActionsTTSAndSocketFiltering() async { let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.main")! defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.main") let store = AppStore(defaults: defaults) - store.userId = "9" - store.accountType = "photog" - store.currentScenicId = 11 - store.scenicQueuePunchSpotId = "22" - store.scenicQueuePunchSpotName = "东门打卡点" - store.saveScenicQueueSettingsSnapshot( + store.session.userId = "9" + store.session.accountType = .unknown("photog") + store.session.currentScenicId = 11 + store.scenicQueue.punchSpotId = "22" + store.scenicQueue.punchSpotName = "东门打卡点" + store.scenicQueue.saveSettingsSnapshot( ScenicQueueSettingsSnapshot(showStartShootingButton: true, quickCallButtonEnabled: true, prepareCallButtonEnabled: true) ) let api = MockScenicQueueAPI() @@ -248,28 +248,28 @@ final class ScenicQueueFeatureTests: XCTestCase { defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.store") let store = AppStore(defaults: defaults) - store.userId = "1" - store.accountType = "photog" - store.scenicQueuePunchSpotId = "22" - store.scenicQueuePunchSpotName = "东门打卡点" - store.scenicQueueRemark = "播报" - store.saveScenicQueueSettingsSnapshot(ScenicQueueSettingsSnapshot(shootMinute: 3)) - store.saveScenicQueuePresetVoices(["a", "b", "c", "d", "e", "f"]) + store.session.userId = "1" + store.session.accountType = .unknown("photog") + store.scenicQueue.punchSpotId = "22" + store.scenicQueue.punchSpotName = "东门打卡点" + store.scenicQueue.remark = "播报" + store.scenicQueue.saveSettingsSnapshot(ScenicQueueSettingsSnapshot(shootMinute: 3)) + store.scenicQueue.savePresetVoices(["a", "b", "c", "d", "e", "f"]) - store.userId = "2" - store.accountType = "photog" - XCTAssertEqual(store.scenicQueuePunchSpotId, "") - XCTAssertNil(store.scenicQueueSettingsSnapshot()) + store.session.userId = "2" + store.session.accountType = .unknown("photog") + XCTAssertEqual(store.scenicQueue.punchSpotId, "") + XCTAssertNil(store.scenicQueue.settingsSnapshot()) - store.userId = "1" - store.accountType = "photog" - XCTAssertEqual(store.scenicQueuePunchSpotId, "22") - XCTAssertEqual(store.scenicQueuePresetVoices(), ["a", "b", "c", "d", "e"]) + store.session.userId = "1" + store.session.accountType = .unknown("photog") + XCTAssertEqual(store.scenicQueue.punchSpotId, "22") + XCTAssertEqual(store.scenicQueue.presetVoices(), ["a", "b", "c", "d", "e"]) store.logout() - store.userId = "1" - store.accountType = "photog" - XCTAssertEqual(store.scenicQueuePunchSpotId, "") - XCTAssertNil(store.scenicQueueSettingsSnapshot()) + store.session.userId = "1" + store.session.accountType = .unknown("photog") + XCTAssertEqual(store.scenicQueue.punchSpotId, "") + XCTAssertNil(store.scenicQueue.settingsSnapshot()) } func testAliyunNLSTokenProviderBuildsSignedCreateTokenRequest() async throws { @@ -321,8 +321,8 @@ final class ScenicQueueFeatureTests: XCTestCase { let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.offlineTTS")! defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.offlineTTS") let store = AppStore(defaults: defaults) - store.userId = "u1" - store.accountType = "photog" + store.session.userId = "u1" + store.session.accountType = .unknown("photog") let tts = MockScenicQueueTTSManager() let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts) var messages: [String] = [] @@ -330,7 +330,7 @@ final class ScenicQueueFeatureTests: XCTestCase { viewModel.setUseOfflineTTS(true) - XCTAssertFalse(store.scenicQueueUseOfflineTTS) + XCTAssertFalse(store.scenicQueue.useOfflineTTS) XCTAssertEqual(messages.last, "离线语音资源未安装") } } diff --git a/suixinkanTests/ScenicSelectionViewModelTests.swift b/suixinkanTests/ScenicSelectionViewModelTests.swift index 91e4091..2902927 100644 --- a/suixinkanTests/ScenicSelectionViewModelTests.swift +++ b/suixinkanTests/ScenicSelectionViewModelTests.swift @@ -24,7 +24,7 @@ final class ScenicSelectionViewModelTests: XCTestCase { } func testSearchFiltersByNameAndAddress() { - appStore.saveRoleScenicList([ + appStore.permissions.saveRoleScenicList([ ScenicInfo(id: 1, name: "西湖景区", status: 1, location: ScenicLocationInfo(lng: 120.1, lat: 30.2, address: "杭州西湖"), coverImg: nil), ScenicInfo(id: 2, name: "灵隐寺", status: 1, location: ScenicLocationInfo(lng: 120.2, lat: 30.3, address: "杭州灵隐"), coverImg: nil), ]) @@ -43,12 +43,12 @@ final class ScenicSelectionViewModelTests: XCTestCase { } func testNearestTagWhenLocationProvided() { - appStore.saveRoleScenicList([ + appStore.permissions.saveRoleScenicList([ ScenicInfo(id: 1, name: "近", status: 1, location: ScenicLocationInfo(lng: 116.40, lat: 39.91, address: "A"), coverImg: nil), ScenicInfo(id: 2, name: "远", status: 1, location: ScenicLocationInfo(lng: 116.50, lat: 39.95, address: "B"), coverImg: nil), ]) let items = ScenicSpotDisplayMapper.buildItems( - scenicList: appStore.roleScenicList(), + scenicList: appStore.permissions.roleScenicList(), searchQuery: "", currentLat: 39.9087, currentLng: 116.3975, @@ -58,7 +58,7 @@ final class ScenicSelectionViewModelTests: XCTestCase { } func testClosedStatusTag() { - appStore.saveRoleScenicList([ + appStore.permissions.saveRoleScenicList([ ScenicInfo(id: 1, name: "停业", status: 2, location: nil, coverImg: nil), ]) let viewModel = ScenicSelectionViewModel(appStore: appStore) @@ -68,7 +68,7 @@ final class ScenicSelectionViewModelTests: XCTestCase { } func testSelectSpotWritesAppStore() { - appStore.saveRoleScenicList([ + appStore.permissions.saveRoleScenicList([ ScenicInfo(id: 5, name: "测试景区", status: 1), ]) let viewModel = ScenicSelectionViewModel(appStore: appStore) @@ -78,8 +78,8 @@ final class ScenicSelectionViewModelTests: XCTestCase { viewModel.loadFromLocal() viewModel.selectSpot(viewModel.spots[0]) - XCTAssertEqual(appStore.currentScenicId, 5) - XCTAssertEqual(appStore.currentScenicName, "测试景区") + XCTAssertEqual(appStore.session.currentScenicId, 5) + XCTAssertEqual(appStore.session.currentScenicName, "测试景区") XCTAssertTrue(completed) XCTAssertTrue(viewModel.spots.first?.isSelected == true) } diff --git a/suixinkanTests/StatisticsViewModelTests.swift b/suixinkanTests/StatisticsViewModelTests.swift index 3252d1e..374a408 100644 --- a/suixinkanTests/StatisticsViewModelTests.swift +++ b/suixinkanTests/StatisticsViewModelTests.swift @@ -11,10 +11,10 @@ import XCTest final class StatisticsViewModelTests: XCTestCase { override func tearDown() { - AppStore.shared.roleCode = "" - AppStore.shared.roleName = "" - AppStore.shared.currentScenicId = 0 - AppStore.shared.currentStoreId = 0 + AppStore.shared.session.roleCode = "" + AppStore.shared.session.roleName = "" + AppStore.shared.session.currentScenicId = 0 + AppStore.shared.session.currentStoreId = 0 super.tearDown() } @@ -68,7 +68,7 @@ final class StatisticsViewModelTests: XCTestCase { {"code":100000,"msg":"success","data":{"total":0,"data":[]}} """.data(using: .utf8)! - AppStore.shared.currentScenicId = 10 + AppStore.shared.session.currentScenicId = 10 let session = MockURLSession(responses: [listJSON]) let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) let viewModel = StatisticsViewModel() @@ -93,7 +93,7 @@ final class StatisticsViewModelTests: XCTestCase { {"code":100000,"msg":"success","data":{"total":1,"data":[{"date":"2025-12-09","order_count":2,"order_price":"50","refund":"10","received":"90"}]}} """.data(using: .utf8)! - AppStore.shared.currentScenicId = 10 + AppStore.shared.session.currentScenicId = 10 let session = MockURLSession(responses: [summaryJSON, listJSON]) let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) let viewModel = StatisticsViewModel() @@ -120,8 +120,8 @@ final class StatisticsViewModelTests: XCTestCase { {"code":100000,"msg":"success","data":{"total":0,"data":[]}} """.data(using: .utf8)! - AppStore.shared.roleCode = AppRoleCode.scenicAdmin.rawValue - AppStore.shared.currentScenicId = 20 + AppStore.shared.session.roleCode = AppRoleCode.scenicAdmin.rawValue + AppStore.shared.session.currentScenicId = 20 let session = MockURLSession(responses: [summaryJSON, listJSON]) let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) let viewModel = StatisticsViewModel() @@ -141,8 +141,8 @@ final class StatisticsViewModelTests: XCTestCase { {"code":100000,"msg":"success","data":{"total":0,"data":[]}} """.data(using: .utf8)! - AppStore.shared.roleCode = AppRoleCode.storeAdmin.rawValue - AppStore.shared.currentStoreId = 30 + AppStore.shared.session.roleCode = AppRoleCode.storeAdmin.rawValue + AppStore.shared.session.currentStoreId = 30 let session = MockURLSession(responses: [summaryJSON, listJSON]) let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) let viewModel = StatisticsViewModel() @@ -156,8 +156,8 @@ final class StatisticsViewModelTests: XCTestCase { } func testStoreAdminWithoutStoreIdDoesNotRequest() async { - AppStore.shared.roleCode = AppRoleCode.storeAdmin.rawValue - AppStore.shared.currentStoreId = 0 + AppStore.shared.session.roleCode = AppRoleCode.storeAdmin.rawValue + AppStore.shared.session.currentStoreId = 0 let session = MockURLSession(responses: []) let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) let viewModel = StatisticsViewModel() @@ -175,7 +175,7 @@ final class StatisticsViewModelTests: XCTestCase { {"code":100000,"msg":"success","data":{"total":1,"data":[{"date":"2025-12-09","order_count":1,"order_price":"10","refund":"0","received":"10"}]}} """.data(using: .utf8)! - AppStore.shared.currentScenicId = 10 + AppStore.shared.session.currentScenicId = 10 let session = MockURLSession(responses: [page1JSON]) let api = StatisticsAPI(client: APIClient(environment: .testing, session: session)) let viewModel = StatisticsViewModel() diff --git a/suixinkanTests/TaskAddViewModelTests.swift b/suixinkanTests/TaskAddViewModelTests.swift index 206a063..1a0ff8e 100644 --- a/suixinkanTests/TaskAddViewModelTests.swift +++ b/suixinkanTests/TaskAddViewModelTests.swift @@ -17,7 +17,7 @@ final class TaskAddViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "TaskAddViewModelTests")! defaults.removePersistentDomain(forName: "TaskAddViewModelTests") appStore = AppStore(defaults: defaults) - appStore.currentScenicId = 10 + appStore.session.currentScenicId = 10 } func testValidateRequiresTaskName() async { diff --git a/suixinkanTests/TaskOrderSelectViewModelTests.swift b/suixinkanTests/TaskOrderSelectViewModelTests.swift index 0e105e1..d7ae4fc 100644 --- a/suixinkanTests/TaskOrderSelectViewModelTests.swift +++ b/suixinkanTests/TaskOrderSelectViewModelTests.swift @@ -17,7 +17,7 @@ final class TaskOrderSelectViewModelTests: XCTestCase { defaults = UserDefaults(suiteName: "TaskOrderSelectViewModelTests")! defaults.removePersistentDomain(forName: "TaskOrderSelectViewModelTests") appStore = AppStore(defaults: defaults) - appStore.currentScenicId = 10 + appStore.session.currentScenicId = 10 } func testToggleSelectionSelectsAndDeselects() { diff --git a/suixinkanTests/TravelAlbumOTGCoreTests.swift b/suixinkanTests/TravelAlbumOTGCoreTests.swift index 8af8d8a..b9e6c57 100644 --- a/suixinkanTests/TravelAlbumOTGCoreTests.swift +++ b/suixinkanTests/TravelAlbumOTGCoreTests.swift @@ -86,9 +86,9 @@ final class TravelAlbumOTGCoreTests: XCTestCase { } func testOSSPunchPointObjectKeyUsesAndroidUserPath() { - let previousUserId = AppStore.shared.userId - AppStore.shared.userId = "u900" - defer { AppStore.shared.userId = previousUserId } + let previousUserId = AppStore.shared.session.userId + AppStore.shared.session.userId = "u900" + defer { AppStore.shared.session.userId = previousUserId } var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index fb8b12d..75833a9 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -782,10 +782,10 @@ private func makeOTGTestDefaults() -> UserDefaults { @MainActor private func prepareAppStoreForOTGTests() { - AppStore.shared.userId = "u1" - AppStore.shared.accountType = "photog" - AppStore.shared.currentScenicId = 11 - AppStore.shared.currentStoreId = 22 + AppStore.shared.session.userId = "u1" + AppStore.shared.session.accountType = .unknown("photog") + AppStore.shared.session.currentScenicId = 11 + AppStore.shared.session.currentStoreId = 22 } private func waitUntil( diff --git a/suixinkanTests/UmengBootstrapTests.swift b/suixinkanTests/UmengBootstrapTests.swift index f2411a5..fe64a8d 100644 --- a/suixinkanTests/UmengBootstrapTests.swift +++ b/suixinkanTests/UmengBootstrapTests.swift @@ -30,7 +30,7 @@ final class UmengBootstrapTests: XCTestCase { UmengBootstrap.setConfigureHandlerForTesting { _, _ in callCount += 1 } - appStore.privacyAgreementAccepted = false + appStore.session.privacyAgreementAccepted = false XCTAssertFalse(UmengBootstrap.configureIfNeeded(appStore: appStore)) XCTAssertFalse(UmengBootstrap.isConfigured) @@ -42,7 +42,7 @@ final class UmengBootstrapTests: XCTestCase { UmengBootstrap.setConfigureHandlerForTesting { appKey, channel in calls.append((appKey, channel)) } - appStore.privacyAgreementAccepted = true + appStore.session.privacyAgreementAccepted = true XCTAssertTrue(UmengBootstrap.configureIfNeeded(appStore: appStore)) XCTAssertTrue(UmengBootstrap.isConfigured)