From 0a0d4fbd79765c52052e4082139fac5b48085766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 22 Jun 2026 11:28:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 6148 bytes .gitattributes | 2 + AGENTS.md | 81 +++ suixinkan.xcodeproj/project.pbxproj | 520 ++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 51 ++ .../UserInterfaceState.xcuserstate | Bin 0 -> 107727 bytes .../xcshareddata/xcschemes/suixinkan.xcscheme | 114 ++++ .../xcschemes/xcschememanagement.plist | 14 + suixinkan/App/App.md | 64 ++ suixinkan/App/Navigation/AppTab.swift | 63 ++ .../App/Navigation/NavigationRouter.swift | 88 +++ suixinkan/App/RootView.swift | 125 ++++ suixinkan/App/State/AccountContext.swift | 129 ++++ .../App/State/AccountContextLoader.swift | 115 ++++ suixinkan/App/State/AppSession.swift | 46 ++ .../App/State/AuthSessionCoordinator.swift | 211 ++++++ suixinkan/App/State/PermissionContext.swift | 83 +++ suixinkan/App/State/ScenicSpotContext.swift | 60 ++ suixinkan/App/State/SessionBootstrapper.swift | 113 ++++ suixinkan/App/State/ToastCenter.swift | 69 ++ suixinkan/App/suixinkanApp.swift | 18 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + suixinkan/Assets.xcassets/Contents.json | 6 + .../LoginBackground.imageset/Contents.json | 21 + .../LoginBackground.imageset/img_login_bg.png | Bin 0 -> 78554 bytes .../Contents.json | 21 + .../icon_cb_privacy_selected.png | Bin 0 -> 1888 bytes .../Contents.json | 21 + .../icon_cb_privacy_unselect.png | Bin 0 -> 2207 bytes .../LoginPwdInvisible.imageset/Contents.json | 21 + .../icon_pwd_invisible.png | Bin 0 -> 3181 bytes .../LoginPwdVisible.imageset/Contents.json | 21 + .../icon_pwd_visible.png | Bin 0 -> 2856 bytes suixinkan/Core/Core.md | 65 ++ suixinkan/Core/Design/AppDesign.swift | 29 + suixinkan/Core/Design/AppMetrics.swift | 67 ++ suixinkan/Core/Networking/APIClient.swift | 257 +++++++ suixinkan/Core/Networking/APIEnvelope.swift | 62 ++ .../Core/Networking/APIEnvironment.swift | 62 ++ suixinkan/Core/Networking/APIError.swift | 61 ++ suixinkan/Core/Networking/APIRequest.swift | 55 ++ suixinkan/Core/Networking/ListPayload.swift | 55 ++ suixinkan/Core/Networking/Networking.md | 305 +++++++++ .../Core/Storage/AccountSnapshotStore.swift | 86 +++ .../Core/Storage/AppPreferencesStore.swift | 46 ++ .../Core/Storage/SessionTokenStore.swift | 102 +++ .../Account/API/AccountContextAPI.swift | 78 +++ suixinkan/Features/Account/Account.md | 36 + .../Account/Models/AccountContextModels.swift | 355 ++++++++++ suixinkan/Features/Auth/API/AuthAPI.swift | 44 ++ suixinkan/Features/Auth/Auth.md | 63 ++ .../Features/Auth/Models/AuthModels.swift | 409 +++++++++++ .../Auth/ViewModels/LoginViewModel.swift | 213 ++++++ .../Auth/Views/AccountSelectionView.swift | 216 ++++++ suixinkan/Features/Auth/Views/LoginView.swift | 482 +++++++++++++ suixinkan/Features/Home/Home.md | 36 + .../Features/Home/Models/HomeMenuItem.swift | 62 ++ .../Home/Routing/HomeMenuRouter.swift | 412 ++++++++++++ .../Home/Services/HomeCommonMenuStore.swift | 91 +++ .../Home/ViewModels/HomeViewModel.swift | 116 ++++ .../Features/Home/Views/HomeIconCatalog.swift | 87 +++ .../Home/Views/HomeMoreFunctionsView.swift | 213 ++++++ .../Home/Views/HomeSupportViews.swift | 82 +++ suixinkan/Features/Home/Views/HomeView.swift | 443 ++++++++++++ suixinkan/Features/Main/Main.md | 40 ++ .../Features/Main/Views/MainTabsView.swift | 38 ++ .../Main/Views/PlaceholderRootViews.swift | 77 +++ .../Features/Profile/API/ProfileAPI.swift | 66 ++ .../Profile/Models/ProfileModels.swift | 142 ++++ suixinkan/Features/Profile/Profile.md | 75 +++ .../Profile/ViewModels/ProfileViewModel.swift | 157 +++++ .../Features/Profile/Views/ProfileView.swift | 636 ++++++++++++++++++ suixinkanTests/APIErrorTests.swift | 29 + .../AccountContextLoaderTests.swift | 137 ++++ suixinkanTests/AccountContextTests.swift | 133 ++++ suixinkanTests/HomeCommonMenuStoreTests.swift | 80 +++ suixinkanTests/HomeMenuRouterTests.swift | 152 +++++ suixinkanTests/HomeViewModelTests.swift | 111 +++ suixinkanTests/LoginViewModelTests.swift | 52 ++ suixinkanTests/NavigationRouterTests.swift | 18 + suixinkanTests/ScenicSpotContextTests.swift | 76 +++ suixinkanTests/StorageTests.swift | 60 ++ 84 files changed, 8899 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitattributes create mode 100644 AGENTS.md create mode 100644 suixinkan.xcodeproj/project.pbxproj create mode 100644 suixinkan.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 suixinkan.xcodeproj/project.xcworkspace/xcuserdata/hanqiu.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme create mode 100644 suixinkan.xcodeproj/xcuserdata/hanqiu.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 suixinkan/App/App.md create mode 100644 suixinkan/App/Navigation/AppTab.swift create mode 100644 suixinkan/App/Navigation/NavigationRouter.swift create mode 100644 suixinkan/App/RootView.swift create mode 100644 suixinkan/App/State/AccountContext.swift create mode 100644 suixinkan/App/State/AccountContextLoader.swift create mode 100644 suixinkan/App/State/AppSession.swift create mode 100644 suixinkan/App/State/AuthSessionCoordinator.swift create mode 100644 suixinkan/App/State/PermissionContext.swift create mode 100644 suixinkan/App/State/ScenicSpotContext.swift create mode 100644 suixinkan/App/State/SessionBootstrapper.swift create mode 100644 suixinkan/App/State/ToastCenter.swift create mode 100644 suixinkan/App/suixinkanApp.swift create mode 100644 suixinkan/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 suixinkan/Assets.xcassets/Contents.json create mode 100644 suixinkan/Assets.xcassets/LoginBackground.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/LoginBackground.imageset/img_login_bg.png create mode 100644 suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/icon_cb_privacy_selected.png create mode 100644 suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/icon_cb_privacy_unselect.png create mode 100644 suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/icon_pwd_invisible.png create mode 100644 suixinkan/Assets.xcassets/LoginPwdVisible.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/LoginPwdVisible.imageset/icon_pwd_visible.png create mode 100644 suixinkan/Core/Core.md create mode 100644 suixinkan/Core/Design/AppDesign.swift create mode 100644 suixinkan/Core/Design/AppMetrics.swift create mode 100644 suixinkan/Core/Networking/APIClient.swift create mode 100644 suixinkan/Core/Networking/APIEnvelope.swift create mode 100644 suixinkan/Core/Networking/APIEnvironment.swift create mode 100644 suixinkan/Core/Networking/APIError.swift create mode 100644 suixinkan/Core/Networking/APIRequest.swift create mode 100644 suixinkan/Core/Networking/ListPayload.swift create mode 100644 suixinkan/Core/Networking/Networking.md create mode 100644 suixinkan/Core/Storage/AccountSnapshotStore.swift create mode 100644 suixinkan/Core/Storage/AppPreferencesStore.swift create mode 100644 suixinkan/Core/Storage/SessionTokenStore.swift create mode 100644 suixinkan/Features/Account/API/AccountContextAPI.swift create mode 100644 suixinkan/Features/Account/Account.md create mode 100644 suixinkan/Features/Account/Models/AccountContextModels.swift create mode 100644 suixinkan/Features/Auth/API/AuthAPI.swift create mode 100644 suixinkan/Features/Auth/Auth.md create mode 100644 suixinkan/Features/Auth/Models/AuthModels.swift create mode 100644 suixinkan/Features/Auth/ViewModels/LoginViewModel.swift create mode 100644 suixinkan/Features/Auth/Views/AccountSelectionView.swift create mode 100644 suixinkan/Features/Auth/Views/LoginView.swift create mode 100644 suixinkan/Features/Home/Home.md create mode 100644 suixinkan/Features/Home/Models/HomeMenuItem.swift create mode 100644 suixinkan/Features/Home/Routing/HomeMenuRouter.swift create mode 100644 suixinkan/Features/Home/Services/HomeCommonMenuStore.swift create mode 100644 suixinkan/Features/Home/ViewModels/HomeViewModel.swift create mode 100644 suixinkan/Features/Home/Views/HomeIconCatalog.swift create mode 100644 suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift create mode 100644 suixinkan/Features/Home/Views/HomeSupportViews.swift create mode 100644 suixinkan/Features/Home/Views/HomeView.swift create mode 100644 suixinkan/Features/Main/Main.md create mode 100644 suixinkan/Features/Main/Views/MainTabsView.swift create mode 100644 suixinkan/Features/Main/Views/PlaceholderRootViews.swift create mode 100644 suixinkan/Features/Profile/API/ProfileAPI.swift create mode 100644 suixinkan/Features/Profile/Models/ProfileModels.swift create mode 100644 suixinkan/Features/Profile/Profile.md create mode 100644 suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift create mode 100644 suixinkan/Features/Profile/Views/ProfileView.swift create mode 100644 suixinkanTests/APIErrorTests.swift create mode 100644 suixinkanTests/AccountContextLoaderTests.swift create mode 100644 suixinkanTests/AccountContextTests.swift create mode 100644 suixinkanTests/HomeCommonMenuStoreTests.swift create mode 100644 suixinkanTests/HomeMenuRouterTests.swift create mode 100644 suixinkanTests/HomeViewModelTests.swift create mode 100644 suixinkanTests/LoginViewModelTests.swift create mode 100644 suixinkanTests/NavigationRouterTests.swift create mode 100644 suixinkanTests/ScenicSpotContextTests.swift create mode 100644 suixinkanTests/StorageTests.swift diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..004e2ec0993a58940758d4eb2f3aa826c4fc9d63 GIT binary patch literal 6148 zcmeHKOG*Pl5Phv#47drnaoKnPA$WmK7*7z~YcisVNoI%!vh1UH4lm$&+`I5qSNj>9 zg(8S(6?DJq>U!Nh52il=+-Oq`fIfhNMbL@UVG{1_+R4d1qDy1Uu);OYSs!%_^czF6 z_I=!7gBi9gZ|mP;iW(Iz&|pn(zPKEWr}eVY&$vDOJabu>l+h+B#UJCje2d z(aGq`TS7QKW5^g4*+X+mB`VeBCx%lx<38twj8RePaA{_alV^T;ytp(w<36Rs1x2e( z0aKu@z^*p?a{iz2m+5@uw<*>#1x$fIrhw~}<8s80%4h3`&y%y(vz)PrNnR9(M!$6l jU_.md` + - 文档需要说明模块职责、核心业务流程、主要页面/ViewModel/API/Model 的关系 + - 新增或修改模块业务逻辑时,需要同步更新对应模块文档 + - 文档描述业务逻辑即可,避免记录临时实现细节或无关调试信息 + + + --- + + ## 界面 + + TabBar + UIKit 风格 + + 尽量少使用 LiquidGlass + + + NavigationStack + + + --- + + ## 第三方库 + + 阿里云 OSS + - 用于上传文件 + + Kingfisher + - 用于显示网络图片 + + + --- + + ## 测试 + + 新增功能和新业务逻辑时,需要同步添加或更新单元测试代码 + - 测试用例需要覆盖核心成功路径、失败路径和关键边界条件 + - 修改现有业务逻辑时,需要同步调整相关测试 + - 提交前需要运行测试 + - 测试不通过时,需要修复问题,直到测试全部通过 + + + --- + + ## 编码风格 + + Swift + + async/await + + 生成的代码添加必要注释 + - 复杂逻辑、关键业务规则、容易误解的地方需要注释 + - 方法名需要添加注释,说明方法的作用 + - 类名、结构体名需要添加注释,说明代表什么实体、承担什么职责 + - 简单赋值、普通布局、显而易见的代码不需要注释 + + + diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a67f015 --- /dev/null +++ b/suixinkan.xcodeproj/project.pbxproj @@ -0,0 +1,520 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + A00000072FE9000000000007 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 939AC78E2FE3F832004B22E4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 939AC7952FE3F832004B22E4; + remoteInfo = suixinkan; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 939AC7962FE3F832004B22E4 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A00000012FE9000000000001 /* suixinkanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 939AC7982FE3F832004B22E4 /* suixinkan */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = suixinkan; + sourceTree = ""; + }; + A00000022FE9000000000002 /* suixinkanTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = suixinkanTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 939AC7932FE3F832004B22E4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A00000052FE9000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 939AC78D2FE3F832004B22E4 = { + isa = PBXGroup; + children = ( + 939AC7982FE3F832004B22E4 /* suixinkan */, + A00000022FE9000000000002 /* suixinkanTests */, + 939AC7972FE3F832004B22E4 /* Products */, + ); + sourceTree = ""; + }; + 939AC7972FE3F832004B22E4 /* Products */ = { + isa = PBXGroup; + children = ( + 939AC7962FE3F832004B22E4 /* suixinkan.app */, + A00000012FE9000000000001 /* suixinkanTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 939AC7952FE3F832004B22E4 /* suixinkan */ = { + isa = PBXNativeTarget; + buildConfigurationList = 939AC7A12FE3F832004B22E4 /* Build configuration list for PBXNativeTarget "suixinkan" */; + buildPhases = ( + 939AC7922FE3F832004B22E4 /* Sources */, + 939AC7932FE3F832004B22E4 /* Frameworks */, + 939AC7942FE3F832004B22E4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 939AC7982FE3F832004B22E4 /* suixinkan */, + ); + name = suixinkan; + packageProductDependencies = ( + ); + productName = suixinkan; + productReference = 939AC7962FE3F832004B22E4 /* suixinkan.app */; + productType = "com.apple.product-type.application"; + }; + A00000032FE9000000000003 /* suixinkanTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A000000B2FE90000000000B /* Build configuration list for PBXNativeTarget "suixinkanTests" */; + buildPhases = ( + A00000042FE9000000000004 /* Sources */, + A00000052FE9000000000005 /* Frameworks */, + A00000062FE9000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A00000082FE9000000000008 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A00000022FE9000000000002 /* suixinkanTests */, + ); + name = suixinkanTests; + packageProductDependencies = ( + ); + productName = suixinkanTests; + productReference = A00000012FE9000000000001 /* suixinkanTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 939AC78E2FE3F832004B22E4 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + 939AC7952FE3F832004B22E4 = { + CreatedOnToolsVersion = 26.5; + }; + A00000032FE9000000000003 = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 939AC7952FE3F832004B22E4; + }; + }; + }; + buildConfigurationList = 939AC7912FE3F832004B22E4 /* Build configuration list for PBXProject "suixinkan" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 939AC78D2FE3F832004B22E4; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 93DAED092FE8E14D00B9E2B1 /* XCRemoteSwiftPackageReference "Kingfisher" */, + 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 939AC7972FE3F832004B22E4 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 939AC7952FE3F832004B22E4 /* suixinkan */, + A00000032FE9000000000003 /* suixinkanTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 939AC7942FE3F832004B22E4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A00000062FE9000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 939AC7922FE3F832004B22E4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A00000042FE9000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A00000082FE9000000000008 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 939AC7952FE3F832004B22E4 /* suixinkan */; + targetProxy = A00000072FE9000000000007 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 939AC79F2FE3F832004B22E4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 56GVN5RNVN; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 939AC7A02FE3F832004B22E4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 56GVN5RNVN; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 939AC7A22FE3F832004B22E4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 17; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkan; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 939AC7A32FE3F832004B22E4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 17; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkan; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + A00000092FE9000000000009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/suixinkan.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan"; + TEST_TARGET_NAME = suixinkan; + }; + name = Debug; + }; + A000000A2FE90000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/suixinkan.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan"; + TEST_TARGET_NAME = suixinkan; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 939AC7912FE3F832004B22E4 /* Build configuration list for PBXProject "suixinkan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 939AC79F2FE3F832004B22E4 /* Debug */, + 939AC7A02FE3F832004B22E4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 939AC7A12FE3F832004B22E4 /* Build configuration list for PBXNativeTarget "suixinkan" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 939AC7A22FE3F832004B22E4 /* Debug */, + 939AC7A32FE3F832004B22E4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A000000B2FE90000000000B /* Build configuration list for PBXNativeTarget "suixinkanTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000092FE9000000000009 /* Debug */, + A000000A2FE90000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 93DAED092FE8E14D00B9E2B1 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.10.0; + }; + }; + 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/aliyun/alibabacloud-oss-swift-sdk-v2.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.2.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + }; + rootObject = 939AC78E2FE3F832004B22E4 /* Project object */; +} diff --git a/suixinkan.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/suixinkan.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/suixinkan.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..5d4bc3f --- /dev/null +++ b/suixinkan.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,51 @@ +{ + "originHash" : "40830d6592be5c997cf1b8848d6a8791afd48ce9a5e48a1f297fbae4a6de3783", + "pins" : [ + { + "identity" : "alibabacloud-oss-swift-sdk-v2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aliyun/alibabacloud-oss-swift-sdk-v2.git", + "state" : { + "revision" : "f9583dda9ea06e7e592739446e983507497a8094", + "version" : "0.2.0" + } + }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "ac632bd26a1c00f139ff62fd01806f21cf67325e", + "version" : "8.10.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "xmlcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CoreOffice/XMLCoder.git", + "state" : { + "revision" : "42f62383dbcd074440cb1f6a750b9c02df9e7325", + "version" : "0.18.2" + } + } + ], + "version" : 3 +} diff --git a/suixinkan.xcodeproj/project.xcworkspace/xcuserdata/hanqiu.xcuserdatad/UserInterfaceState.xcuserstate b/suixinkan.xcodeproj/project.xcworkspace/xcuserdata/hanqiu.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..8310bd78285de167e5cd5094227f579ed264ab0c GIT binary patch literal 107727 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 M#sBZWkd|lv56H6{x&QzG literal 0 HcmV?d00001 diff --git a/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme b/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme new file mode 100644 index 0000000..21a566e --- /dev/null +++ b/suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/suixinkan.xcodeproj/xcuserdata/hanqiu.xcuserdatad/xcschemes/xcschememanagement.plist b/suixinkan.xcodeproj/xcuserdata/hanqiu.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..cfeb88d --- /dev/null +++ b/suixinkan.xcodeproj/xcuserdata/hanqiu.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + suixinkan.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md new file mode 100644 index 0000000..7a595f1 --- /dev/null +++ b/suixinkan/App/App.md @@ -0,0 +1,64 @@ +# App 模块业务逻辑 + +## 模块职责 + +App 模块负责应用入口、根视图切换、全局状态注入、主导航状态、登录态恢复和全局 Toast。 + +该模块不直接处理具体页面业务,主要承担 App 级基础设施编排: +- 根据登录状态展示登录页、恢复态或主 Tab。 +- 创建并注入全局状态和共享服务。 +- 管理每个 Tab 独立的 `NavigationStack` 路径。 +- 协调登录成功、退出登录、冷启动恢复和本地缓存同步。 + +## 核心对象 + +- `suixinkanApp`:SwiftUI 应用入口,挂载 `RootView`。 +- `RootView`:创建 `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter`、`APIClient` 和业务 API,并注入 SwiftUI Environment。 +- `AppSession`:保存认证阶段和正式 token,只负责登录态,不承载业务资料。 +- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。 +- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。 +- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。 +- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。 +- `ToastCenter`:保存当前全局 Toast 文案。 +- `AuthSessionCoordinator`:统一处理登录完成、退出登录、偏好读取和账号快照刷新。 +- `SessionBootstrapper`:冷启动时读取本地 token 和账号快照,并向服务端校验登录态。 +- `AccountContextLoader`:统一同步用户资料、角色权限、景区和门店。 + +## 启动流程 + +1. `suixinkanApp` 创建 `RootView`。 +2. `RootView` 初始化共享依赖,并把它们注入 Environment。 +3. `APIClient` 绑定 `AppSession.token` 作为默认 token provider。 +4. `SessionBootstrapper.restore` 尝试从 Keychain 读取正式 token。 +5. 无 token 时保持 `loggedOut`,展示 `LoginView`。 +6. 有 token 时进入 `restoring`,先恢复本地账号快照。 +7. `AccountContextLoader` 并行请求用户资料和角色权限,再补全景区与门店。 +8. 校验成功后进入 `loggedIn`,展示 `MainTabsView`。 +9. `ScenicSpotContext` 按当前景区懒加载景点/打卡点。 +10. 明确 token 失效时清空 token 和账号快照,回到登录页。 +11. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 + +## 登录和退出 + +登录完成由 `AuthSessionCoordinator.completeLogin` 统一处理: +- 正式 token 写入 `SessionTokenStore`。 +- 上次手机号和协议状态写入 `AppPreferencesStore`。 +- 账号资料、景区列表、门店列表写入 `AccountContext`。 +- 角色权限和当前角色写入 `PermissionContext`。 +- 非敏感账号快照写入 `AccountSnapshotStore`。 +- `AppSession` 切换为 `loggedIn`。 + +退出登录由 `AuthSessionCoordinator.logout` 统一处理: +- 清空 Keychain token。 +- 清空账号快照。 +- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。 +- `AppSession` 切换为 `loggedOut`。 +- 保留上次手机号、协议状态等非敏感偏好。 + +## 导航规则 + +主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。 + +Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,`MainTabsView` 会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar,避免每个业务页面重复处理。 + +当前路由枚举 `AppRoute` 仍以占位详情页为主,后续新增真实页面时应优先扩展 `AppRoute`,再由对应 Tab 的 `NavigationStack` 处理跳转。 diff --git a/suixinkan/App/Navigation/AppTab.swift b/suixinkan/App/Navigation/AppTab.swift new file mode 100644 index 0000000..52fad57 --- /dev/null +++ b/suixinkan/App/Navigation/AppTab.swift @@ -0,0 +1,63 @@ +// +// AppTab.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import SwiftUI + +/// 主 Tab 实体,描述底部导航中的每一个一级入口。 +enum AppTab: String, CaseIterable, Identifiable, Hashable { + case home + case orders + case statistics + case profile + + var id: Self { self } + + var title: String { + switch self { + case .home: + "首页" + case .orders: + "订单" + case .statistics: + "数据" + case .profile: + "我的" + } + } + + var systemImage: String { + switch self { + case .home: + "house" + case .orders: + "doc.text" + case .statistics: + "chart.bar" + case .profile: + "person" + } + } + + @ViewBuilder + var rootView: some View { + switch self { + case .home: + HomeRootView() + case .orders: + OrdersRootView() + case .statistics: + StatisticsRootView() + case .profile: + ProfileRootView() + } + } + + @ViewBuilder + var label: some View { + Label(title, systemImage: systemImage) + } +} diff --git a/suixinkan/App/Navigation/NavigationRouter.swift b/suixinkan/App/Navigation/NavigationRouter.swift new file mode 100644 index 0000000..38b558d --- /dev/null +++ b/suixinkan/App/Navigation/NavigationRouter.swift @@ -0,0 +1,88 @@ +// +// NavigationRouter.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Observation +import SwiftUI + +/// 应用内可导航目标,承载各 Tab 内部的路由目的地。 +enum AppRoute: Hashable { + case placeholder(title: String) + case home(HomeRoute) + + /// 返回该路由通过 NavigationStack push 后是否隐藏底部 TabBar。 + var hidesTabBarWhenPushed: Bool { + true + } + + /// 构建当前路由对应的目标页面。 + @ViewBuilder + var destinationView: some View { + switch self { + case .placeholder(let title): + PlaceholderDetailView(title: title) + case .home(let route): + route.destinationView + } + } +} + +@MainActor +@Observable +/// 单个 NavigationStack 的路径容器,用于保存某个 Tab 的导航历史。 +final class RouterPath { + var path: [AppRoute] = [] + + /// 将指定路由压入当前 Tab 的导航栈。 + func navigate(to route: AppRoute) { + path.append(route) + } + + /// 清空当前 Tab 的导航栈。 + func reset() { + path = [] + } +} + +@MainActor +@Observable +/// 主导航状态中心,管理当前 Tab 和每个 Tab 独立的 NavigationStack 路径。 +final class AppRouter { + var selectedTab: AppTab = .home + private var routers: [AppTab: RouterPath] = [:] + + /// 获取指定 Tab 对应的路由路径容器,不存在时自动创建。 + func router(for tab: AppTab) -> RouterPath { + if let router = routers[tab] { + return router + } + + let router = RouterPath() + routers[tab] = router + return router + } + + /// 生成 SwiftUI NavigationStack 可使用的路径绑定。 + func binding(for tab: AppTab) -> Binding<[AppRoute]> { + let router = router(for: tab) + + return Binding( + get: { router.path }, + set: { router.path = $0 } + ) + } + + /// 切换到指定主 Tab。 + func select(_ tab: AppTab) { + selectedTab = tab + } + + /// 重置主 Tab 选择和所有 Tab 的导航历史。 + func reset() { + selectedTab = .home + routers.values.forEach { $0.reset() } + } +} diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift new file mode 100644 index 0000000..bec550b --- /dev/null +++ b/suixinkan/App/RootView.swift @@ -0,0 +1,125 @@ +// +// RootView.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import SwiftUI + +/// App 根视图,负责创建全局依赖并根据登录状态切换登录页和主界面。 +struct RootView: View { + @State private var appSession = AppSession() + @State private var accountContext = AccountContext() + @State private var permissionContext = PermissionContext() + @State private var scenicSpotContext = ScenicSpotContext() + @State private var appRouter = AppRouter() + @State private var toastCenter = ToastCenter() + @State private var apiClient: APIClient + @State private var authAPI: AuthAPI + @State private var profileAPI: ProfileAPI + @State private var accountContextAPI: AccountContextAPI + @State private var authSessionCoordinator: AuthSessionCoordinator + @State private var sessionBootstrapper: SessionBootstrapper + + /// 初始化网络客户端和业务 API,确保它们共享同一个 token provider。 + init() { + let apiClient = APIClient() + let tokenStore = SessionTokenStore() + let snapshotStore = AccountSnapshotStore() + _apiClient = State(initialValue: apiClient) + _authAPI = State(initialValue: AuthAPI(client: apiClient)) + _profileAPI = State(initialValue: ProfileAPI(client: apiClient)) + _accountContextAPI = State(initialValue: AccountContextAPI(client: apiClient)) + _authSessionCoordinator = State( + initialValue: AuthSessionCoordinator( + tokenStore: tokenStore, + snapshotStore: snapshotStore, + preferencesStore: AppPreferencesStore() + ) + ) + _sessionBootstrapper = State( + initialValue: SessionBootstrapper( + tokenStore: tokenStore, + snapshotStore: snapshotStore + ) + ) + } + + var body: some View { + rootContent + .globalToastOverlay() + .environment(appSession) + .environment(accountContext) + .environment(permissionContext) + .environment(scenicSpotContext) + .environment(appRouter) + .environment(toastCenter) + .environment(apiClient) + .environment(authAPI) + .environment(profileAPI) + .environment(accountContextAPI) + .environment(authSessionCoordinator) + .task { + apiClient.bindAuthTokenProvider { appSession.token } + await sessionBootstrapper.restore( + appSession: appSession, + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: profileAPI, + accountContextAPI: accountContextAPI, + toastCenter: toastCenter + ) + } + .task(id: scenicSpotTaskID) { + guard appSession.isLoggedIn else { + scenicSpotContext.reset() + return + } + await scenicSpotContext.reload( + scenicId: accountContext.currentScenic?.id, + api: accountContextAPI + ) + } + .onChange(of: appSession.phase) { _, phase in + guard phase == .loggedOut else { return } + accountContext.reset() + permissionContext.reset() + scenicSpotContext.reset() + appRouter.reset() + toastCenter.dismiss() + } + } + + @ViewBuilder + private var rootContent: some View { + switch appSession.phase { + case .loggedOut: + LoginView() + case .restoring: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + case .loggedIn: + MainTabsView() + } + } + + /// 景点加载任务的稳定标识,确保登录状态和当前景区变化都会触发任务。 + private var scenicSpotTaskID: String { + let phaseText: String + switch appSession.phase { + case .loggedOut: + phaseText = "loggedOut" + case .restoring: + phaseText = "restoring" + case .loggedIn: + phaseText = "loggedIn" + } + return "\(phaseText)-\(accountContext.currentScenic?.id ?? 0)" + } +} + +#Preview { + RootView() +} diff --git a/suixinkan/App/State/AccountContext.swift b/suixinkan/App/State/AccountContext.swift new file mode 100644 index 0000000..b0cef9a --- /dev/null +++ b/suixinkan/App/State/AccountContext.swift @@ -0,0 +1,129 @@ +// +// AccountContext.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Observation + +/// 当前账号的基础资料实体,用于跨页面展示昵称、手机号和头像。 +struct AccountProfile: Codable, Equatable { + var userId: String + var displayName: String + var phone: String? + var avatarURL: String? +} + +/// 当前业务作用域实体,表示景区或门店维度的上下文。 +struct BusinessScope: Codable, Equatable, Identifiable { + /// 业务作用域类型,用于区分景区和门店。 + enum Kind: Codable, Equatable { + case scenic + case store + } + + var id: Int + var name: String + var kind: Kind + var parentScenicId: Int? + + /// 创建业务作用域,门店可通过 parentScenicId 标记所属景区。 + init(id: Int, name: String, kind: Kind, parentScenicId: Int? = nil) { + self.id = id + self.name = name + self.kind = kind + self.parentScenicId = parentScenicId + } +} + +@MainActor +@Observable +/// 账号上下文状态中心,保存当前账号资料和景区/门店作用域。 +final class AccountContext { + private(set) var profile: AccountProfile? + private(set) var scenicScopes: [BusinessScope] = [] + private(set) var storeScopes: [BusinessScope] = [] + var currentScenic: BusinessScope? + var currentStore: BusinessScope? + + /// 应用登录成功后写入账号资料。 + func applyLogin(profile: AccountProfile? = nil) { + self.profile = profile + } + + /// 替换当前账号资料,通常用于用户信息刷新后同步全局展示。 + func replaceProfile(_ profile: AccountProfile?) { + self.profile = profile + } + + /// 替换景区和门店作用域,并尽量保持当前选择不变。 + func replaceScopes( + scenic: [BusinessScope], + stores: [BusinessScope], + currentScenicId: Int? = nil, + currentStoreId: Int? = nil + ) { + scenicScopes = scenic + storeScopes = stores + currentScenic = currentScenicId.flatMap { id in + scenic.first { $0.id == id } + } ?? currentScenic.flatMap { current in + scenic.first { $0.id == current.id } + } ?? scenic.first + currentStore = resolvedStore( + in: stores, + currentScenic: currentScenic, + preferredStoreId: currentStoreId ?? currentStore?.id + ) + } + + /// 切换当前景区,并按景区重新校准当前门店。 + func selectScenic(id scenicId: Int) { + guard let scenic = scenicScopes.first(where: { $0.id == scenicId }) else { return } + currentScenic = scenic + currentStore = resolvedStore( + in: storeScopes, + currentScenic: scenic, + preferredStoreId: currentStore?.id + ) + } + + /// 切换当前门店,并在门店有关联景区时同步当前景区。 + func selectStore(id storeId: Int) { + guard let store = storeScopes.first(where: { $0.id == storeId }) else { return } + currentStore = store + if let scenicId = store.parentScenicId, + currentScenic?.id != scenicId, + let scenic = scenicScopes.first(where: { $0.id == scenicId }) { + currentScenic = scenic + } + } + + /// 清空账号上下文,通常在退出登录时调用。 + func reset() { + profile = nil + scenicScopes = [] + storeScopes = [] + currentScenic = nil + currentStore = nil + } + + /// 按当前景区和期望门店 ID 选择有效门店。 + private func resolvedStore( + in stores: [BusinessScope], + currentScenic: BusinessScope?, + preferredStoreId: Int? + ) -> BusinessScope? { + let scenicId = currentScenic?.id + let storesForScenic = stores.filter { store in + guard let scenicId else { return true } + return store.parentScenicId == nil || store.parentScenicId == scenicId + } + if let preferredStoreId, + let store = storesForScenic.first(where: { $0.id == preferredStoreId }) { + return store + } + return storesForScenic.first + } +} diff --git a/suixinkan/App/State/AccountContextLoader.swift b/suixinkan/App/State/AccountContextLoader.swift new file mode 100644 index 0000000..2981263 --- /dev/null +++ b/suixinkan/App/State/AccountContextLoader.swift @@ -0,0 +1,115 @@ +// +// AccountContextLoader.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 用户资料服务协议,抽象用户信息读取能力以便测试替换。 +protocol UserProfileServing { + /// 获取当前登录账号的用户资料。 + func userInfo() async throws -> UserInfoResponse +} + +@MainActor +/// 账号上下文加载器,统一装配用户资料、角色权限、景区和门店上下文。 +struct AccountContextLoader { + /// 刷新账号上下文,权限失败会向外抛错,景区和门店按旧工程规则兜底。 + func refresh( + accountContext: AccountContext, + permissionContext: PermissionContext, + profileAPI: UserProfileServing, + accountContextAPI: AccountContextServing, + cachedCurrentRoleId: Int? = nil, + cachedCurrentScenicId: Int? = nil, + cachedCurrentStoreId: Int? = nil + ) async throws { + async let userData = profileAPI.userInfo() + async let roleData = accountContextAPI.rolePermissions() + let (userInfo, rolePermissions) = try await (userData, roleData) + + permissionContext.replaceRolePermissions(rolePermissions, currentRoleId: cachedCurrentRoleId) + + let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions) + let storesResponse = await loadStores(accountContextAPI: accountContextAPI) + let profile = makeProfile(from: userInfo, fallback: accountContext.profile) + let scenicScopes = scopedScenics( + permissionContext: permissionContext, + scenicResponse: scenicResponse + ) + let storeScopes = storesResponse?.list.map { store in + BusinessScope(id: store.id, name: store.name, kind: .store, parentScenicId: store.scenicId) + } ?? [] + + accountContext.replaceProfile(profile) + accountContext.replaceScopes( + scenic: scenicScopes, + stores: storeScopes, + currentScenicId: cachedCurrentScenicId, + currentStoreId: cachedCurrentStoreId + ) + } + + /// 景区列表接口失败时,从角色权限中的景区数据去重构造兜底列表。 + private func loadScenicList( + accountContextAPI: AccountContextServing, + rolePermissions: [RolePermissionResponse] + ) async -> ScenicListAllResponse { + do { + return try await accountContextAPI.scenicListAll() + } catch { + let fallback = uniqueScenics(from: rolePermissions) + return ScenicListAllResponse(total: fallback.count, list: fallback) + } + } + + /// 门店列表接口失败时返回 nil,不阻断登录和恢复流程。 + private func loadStores(accountContextAPI: AccountContextServing) async -> ListPayload? { + try? await accountContextAPI.storeAll() + } + + /// 根据当前角色优先返回角色可访问景区,角色无景区时使用景区列表接口结果。 + private func scopedScenics( + permissionContext: PermissionContext, + scenicResponse: ScenicListAllResponse + ) -> [BusinessScope] { + let roleScenics = permissionContext.currentRoleScenicScopes() + if !roleScenics.isEmpty { + return roleScenics + } + return scenicResponse.list.map { + BusinessScope(id: $0.id, name: $0.name, kind: .scenic) + } + } + + /// 从角色权限中去重生成景区列表。 + private func uniqueScenics(from rolePermissions: [RolePermissionResponse]) -> [ScenicListItem] { + var seen = Set() + var result: [ScenicListItem] = [] + for permission in rolePermissions { + for scenic in permission.scenic where seen.insert(scenic.id).inserted { + result.append(ScenicListItem(id: scenic.id, name: scenic.name)) + } + } + return result + } + + /// 将用户资料接口响应转换为全局账号资料。 + private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile { + AccountProfile( + userId: fallback?.userId ?? "", + displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称", + phone: nonEmpty(userInfo.phone) ?? fallback?.phone, + avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL + ) + } + + /// 去除空白字符后返回非空字符串。 + private func nonEmpty(_ value: String?) -> String? { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan/App/State/AppSession.swift b/suixinkan/App/State/AppSession.swift new file mode 100644 index 0000000..5c197df --- /dev/null +++ b/suixinkan/App/State/AppSession.swift @@ -0,0 +1,46 @@ +// +// AppSession.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation +import Observation + +/// 登录阶段实体,表示根视图当前应该展示的认证状态。 +enum AuthPhase: Equatable { + case loggedOut + case restoring + case loggedIn +} + +@MainActor +@Observable +/// 登录会话状态中心,保存 token 和当前认证阶段。 +final class AppSession { + private(set) var phase: AuthPhase = .loggedOut + private(set) var token: String? + + var isLoggedIn: Bool { + phase == .loggedIn + } + + /// 将会话切换到恢复中状态,用于冷启动恢复登录态。 + func beginRestoring(token: String? = nil) { + self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines) + phase = .restoring + } + + /// 标记登录成功并保存正式 token。 + func markLoggedIn(token: String? = nil) { + self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines) + phase = .loggedIn + } + + /// 清空 token 并回到未登录状态。 + func logout() { + token = nil + phase = .loggedOut + } +} diff --git a/suixinkan/App/State/AuthSessionCoordinator.swift b/suixinkan/App/State/AuthSessionCoordinator.swift new file mode 100644 index 0000000..e85afa3 --- /dev/null +++ b/suixinkan/App/State/AuthSessionCoordinator.swift @@ -0,0 +1,211 @@ +// +// AuthSessionCoordinator.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 登录会话协调器,统一处理登录完成、退出登录和缓存同步。 +final class AuthSessionCoordinator { + @ObservationIgnored private let tokenStore: SessionTokenStore + @ObservationIgnored private let snapshotStore: AccountSnapshotStore + @ObservationIgnored private let preferencesStore: AppPreferencesStore + + /// 初始化登录会话协调器,并注入 token、账号快照和偏好存储。 + init( + tokenStore: SessionTokenStore, + snapshotStore: AccountSnapshotStore, + preferencesStore: AppPreferencesStore + ) { + self.tokenStore = tokenStore + self.snapshotStore = snapshotStore + self.preferencesStore = preferencesStore + } + + /// 创建使用默认存储服务的登录会话协调器。 + convenience init() { + self.init( + tokenStore: SessionTokenStore(), + snapshotStore: AccountSnapshotStore(), + preferencesStore: AppPreferencesStore() + ) + } + + /// 读取登录页需要的本地偏好。 + func loginPreferences() -> LoginPreferences { + LoginPreferences( + lastUsername: preferencesStore.loadLastLoginUsername(), + privacyAgreementAccepted: preferencesStore.loadPrivacyAgreementAccepted() + ) + } + + /// 完成正式登录,写入内存状态、Keychain token 和账号快照。 + func completeLogin( + with response: V9AuthResponse, + username: String, + privacyAgreementAccepted: Bool, + appSession: AppSession, + accountContext: AccountContext, + permissionContext: PermissionContext, + profileAPI: UserProfileServing, + accountContextAPI: AccountContextServing + ) async throws { + let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) + try tokenStore.save(token) + preferencesStore.saveLastLoginUsername(username) + preferencesStore.savePrivacyAgreementAccepted(privacyAgreementAccepted) + + let profile = response.primaryProfile + let scenicScopes = response.scenicScopes + let storeScopes = response.storeScopes + let identity = response.currentAccountIdentity + accountContext.applyLogin(profile: profile) + accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes) + appSession.beginRestoring(token: token) + + do { + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: profileAPI, + accountContextAPI: accountContextAPI, + cachedCurrentScenicId: accountContext.currentScenic?.id, + cachedCurrentStoreId: accountContext.currentStore?.id + ) + } catch { + if APIError.isAuthenticationExpired(error) { + try? tokenStore.clear() + snapshotStore.clear() + accountContext.reset() + permissionContext.reset() + appSession.logout() + throw error + } + appSession.markLoggedIn(token: token) + saveSnapshot( + profile: accountContext.profile ?? profile, + accountType: identity?.accountType, + businessUserId: identity?.businessUserId, + permissionContext: permissionContext, + accountContext: accountContext + ) + throw error + } + + saveSnapshot( + profile: accountContext.profile ?? profile, + accountType: identity?.accountType, + businessUserId: identity?.businessUserId, + permissionContext: permissionContext, + accountContext: accountContext + ) + appSession.markLoggedIn(token: token) + } + + /// 使用最新用户资料刷新账号快照中的展示信息。 + func refreshCachedProfile(from userInfo: UserInfoResponse, accountContext: AccountContext) { + let profile = makeProfile(from: userInfo, fallback: accountContext.profile) + accountContext.replaceProfile(profile) + saveSnapshot( + profile: profile, + accountType: snapshotStore.load()?.accountType, + businessUserId: snapshotStore.load()?.businessUserId, + currentRoleId: snapshotStore.load()?.currentRoleId, + accountContext: accountContext + ) + } + + /// 退出登录并清空敏感缓存,保留手机号和协议状态等偏好。 + func logout( + appSession: AppSession, + accountContext: AccountContext, + permissionContext: PermissionContext, + scenicSpotContext: ScenicSpotContext, + appRouter: AppRouter, + toastCenter: ToastCenter + ) { + try? tokenStore.clear() + snapshotStore.clear() + accountContext.reset() + permissionContext.reset() + scenicSpotContext.reset() + appRouter.reset() + toastCenter.dismiss() + appSession.logout() + } + + /// 保存当前账号上下文的快照。 + private func saveSnapshot( + profile: AccountProfile?, + accountType: String?, + businessUserId: Int?, + permissionContext: PermissionContext? = nil, + currentRoleId: Int? = nil, + accountContext: AccountContext + ) { + snapshotStore.save( + AccountSnapshot( + profile: profile, + accountType: accountType, + businessUserId: businessUserId, + currentRoleId: permissionContext?.currentRole?.id ?? currentRoleId, + scenicScopes: accountContext.scenicScopes, + storeScopes: accountContext.storeScopes, + currentScenicId: accountContext.currentScenic?.id, + currentStoreId: accountContext.currentStore?.id + ) + ) + } + + /// 将用户资料接口响应转换为全局账号资料。 + private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile { + AccountProfile( + userId: fallback?.userId ?? "", + displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称", + phone: nonEmpty(userInfo.phone) ?? fallback?.phone, + avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL + ) + } + + /// 去除空白字符后返回非空字符串。 + private func nonEmpty(_ value: String?) -> String? { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } +} + +/// 登录页偏好实体,表示可安全恢复到表单的非敏感信息。 +struct LoginPreferences: Equatable { + let lastUsername: String? + let privacyAgreementAccepted: Bool +} + +private extension V9AuthResponse { + /// 当前账号身份实体,表示登录后实际选中的业务账号。 + struct CurrentAccountIdentity { + let accountType: String + let businessUserId: Int + } + + /// 提取当前账号身份,优先使用后端标记的 isCurrent 账号。 + var currentAccountIdentity: CurrentAccountIdentity? { + if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first { + return CurrentAccountIdentity( + accountType: storeUser.accountType, + businessUserId: storeUser.businessUserId + ) + } + if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first { + return CurrentAccountIdentity( + accountType: scenicUser.accountType, + businessUserId: scenicUser.businessUserId + ) + } + return nil + } +} diff --git a/suixinkan/App/State/PermissionContext.swift b/suixinkan/App/State/PermissionContext.swift new file mode 100644 index 0000000..ec0ef65 --- /dev/null +++ b/suixinkan/App/State/PermissionContext.swift @@ -0,0 +1,83 @@ +// +// PermissionContext.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 权限上下文状态中心,保存角色权限、当前角色和扁平化权限 URI。 +final class PermissionContext { + private(set) var rolePermissions: [RolePermissionResponse] = [] + private(set) var permissionURIs: Set = [] + var currentRole: RoleInfo? + + /// 替换角色权限列表,并尽量按缓存角色 ID 保持当前角色。 + func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) { + self.rolePermissions = rolePermissions + currentRole = currentRoleId.flatMap { id in + rolePermissions.first { $0.role.id == id }?.role + } ?? currentRole.flatMap { role in + rolePermissions.first { $0.role.id == role.id }?.role + } ?? rolePermissions.first?.role + permissionURIs = Set(Self.flattenPermissions(currentRole?.permission ?? [])) + } + + /// 切换当前角色,并同步账号上下文中的景区和门店选择。 + func selectRole(id roleId: Int, accountContext: AccountContext) { + guard let rolePermission = rolePermissions.first(where: { $0.role.id == roleId }) else { return } + currentRole = rolePermission.role + permissionURIs = Set(Self.flattenPermissions(rolePermission.role.permission)) + + let scenicScopes = Self.uniqueScenicScopes(from: rolePermission.scenic) + accountContext.replaceScopes( + scenic: scenicScopes, + stores: accountContext.storeScopes, + currentScenicId: accountContext.currentScenic?.id, + currentStoreId: accountContext.currentStore?.id + ) + } + + /// 判断当前角色是否拥有指定 URI 权限。 + func canAccess(_ uri: String) -> Bool { + permissionURIs.contains(uri.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + /// 返回当前角色关联的景区作用域。 + func currentRoleScenicScopes() -> [BusinessScope] { + guard let currentRole else { return [] } + guard let rolePermission = rolePermissions.first(where: { $0.role.id == currentRole.id }) else { + return [] + } + return Self.uniqueScenicScopes(from: rolePermission.scenic) + } + + /// 清空权限上下文,通常在退出登录时调用。 + func reset() { + rolePermissions = [] + permissionURIs = [] + currentRole = nil + } + + /// 递归提取权限树里的非空 URI。 + private static func flattenPermissions(_ permissions: [PermissionItem]) -> [String] { + permissions.flatMap { item -> [String] in + let current = item.uri.trimmingCharacters(in: .whitespacesAndNewlines) + let children = flattenPermissions(item.children) + return current.isEmpty ? children : [current] + children + } + } + + /// 将角色关联景区去重并转换为业务作用域。 + private static func uniqueScenicScopes(from scenics: [ScenicInfo]) -> [BusinessScope] { + var seen = Set() + return scenics.compactMap { scenic in + guard seen.insert(scenic.id).inserted else { return nil } + return BusinessScope(id: scenic.id, name: scenic.name, kind: .scenic) + } + } +} diff --git a/suixinkan/App/State/ScenicSpotContext.swift b/suixinkan/App/State/ScenicSpotContext.swift new file mode 100644 index 0000000..dd61610 --- /dev/null +++ b/suixinkan/App/State/ScenicSpotContext.swift @@ -0,0 +1,60 @@ +// +// ScenicSpotContext.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +/// 景点加载阶段实体,表示当前景区景点列表的加载状态。 +enum ScenicSpotLoadState: Equatable { + case idle + case loading + case loaded + case failed(String) +} + +@MainActor +@Observable +/// 景点上下文状态中心,保存当前景区下的景点或打卡点列表。 +final class ScenicSpotContext { + private(set) var scenicId: Int? + private(set) var spots: [ScenicSpotItem] = [] + private(set) var loadState: ScenicSpotLoadState = .idle + + /// 按景区 ID 重新加载景点列表,失败只影响景点模块本身。 + func reload(scenicId: Int?, api: AccountContextServing) async { + guard let scenicId else { + reset() + return + } + + if self.scenicId != scenicId { + spots = [] + } + self.scenicId = scenicId + loadState = .loading + + do { + let response = try await api.scenicSpotListAll(scenicId: scenicId) + guard !Task.isCancelled else { return } + spots = response.list + loadState = .loaded + } catch is CancellationError { + loadState = .idle + } catch { + guard !Task.isCancelled else { return } + spots = [] + loadState = .failed(error.localizedDescription) + } + } + + /// 清空景点上下文,通常在退出登录或没有当前景区时调用。 + func reset() { + scenicId = nil + spots = [] + loadState = .idle + } +} diff --git a/suixinkan/App/State/SessionBootstrapper.swift b/suixinkan/App/State/SessionBootstrapper.swift new file mode 100644 index 0000000..35220f7 --- /dev/null +++ b/suixinkan/App/State/SessionBootstrapper.swift @@ -0,0 +1,113 @@ +// +// SessionBootstrapper.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 登录态启动恢复器,负责冷启动时从缓存恢复并校验 token。 +final class SessionBootstrapper { + @ObservationIgnored private let tokenStore: SessionTokenStore + @ObservationIgnored private let snapshotStore: AccountSnapshotStore + private var didAttemptRestore = false + + /// 初始化启动恢复器,并注入 token 与账号快照存储。 + init( + tokenStore: SessionTokenStore, + snapshotStore: AccountSnapshotStore + ) { + self.tokenStore = tokenStore + self.snapshotStore = snapshotStore + } + + /// 创建使用默认存储服务的启动恢复器。 + convenience init() { + self.init( + tokenStore: SessionTokenStore(), + snapshotStore: AccountSnapshotStore() + ) + } + + /// 从本地缓存恢复登录态并请求服务端校验。 + func restore( + appSession: AppSession, + accountContext: AccountContext, + permissionContext: PermissionContext, + profileAPI: UserProfileServing, + accountContextAPI: AccountContextServing, + toastCenter: ToastCenter + ) async { + guard !didAttemptRestore, !appSession.isLoggedIn else { return } + didAttemptRestore = true + + guard let token = tokenStore.load() else { + appSession.logout() + return + } + + appSession.beginRestoring(token: token) + let cachedSnapshot = restoreCachedSnapshot(to: accountContext) + + do { + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: profileAPI, + accountContextAPI: accountContextAPI, + cachedCurrentRoleId: cachedSnapshot?.currentRoleId, + cachedCurrentScenicId: cachedSnapshot?.currentScenicId, + cachedCurrentStoreId: cachedSnapshot?.currentStoreId + ) + saveLatestSnapshot(from: accountContext, permissionContext: permissionContext) + appSession.markLoggedIn(token: token) + } catch { + if APIError.isAuthenticationExpired(error) { + try? tokenStore.clear() + snapshotStore.clear() + accountContext.reset() + permissionContext.reset() + appSession.logout() + toastCenter.show("登录状态已失效,请重新登录") + } else { + appSession.markLoggedIn(token: token) + toastCenter.show("网络异常,已使用本地登录状态") + } + } + } + + /// 将缓存快照恢复到账号上下文。 + private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? { + guard let snapshot = snapshotStore.load() else { return nil } + accountContext.applyLogin(profile: snapshot.profile) + accountContext.replaceScopes( + scenic: snapshot.scenicScopes, + stores: snapshot.storeScopes, + currentScenicId: snapshot.currentScenicId, + currentStoreId: snapshot.currentStoreId + ) + return snapshot + } + + /// 保存服务端校验后的最新账号上下文。 + private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) { + let existing = snapshotStore.load() + snapshotStore.save( + AccountSnapshot( + profile: accountContext.profile, + accountType: existing?.accountType, + businessUserId: existing?.businessUserId, + currentRoleId: permissionContext.currentRole?.id ?? existing?.currentRoleId, + scenicScopes: accountContext.scenicScopes, + storeScopes: accountContext.storeScopes, + currentScenicId: accountContext.currentScenic?.id, + currentStoreId: accountContext.currentStore?.id + ) + ) + } + +} diff --git a/suixinkan/App/State/ToastCenter.swift b/suixinkan/App/State/ToastCenter.swift new file mode 100644 index 0000000..5d838f2 --- /dev/null +++ b/suixinkan/App/State/ToastCenter.swift @@ -0,0 +1,69 @@ +// +// ToastCenter.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Observation +import SwiftUI + +@MainActor +@Observable +/// 全局 Toast 状态中心,负责保存和清除当前提示文案。 +final class ToastCenter { + fileprivate var message: String? + + /// 显示一条全局 Toast 文案。 + func show(_ message: String) { + self.message = message + } + + /// 清除当前 Toast 文案。 + func dismiss() { + message = nil + } +} + +/// 全局 Toast 叠加层修饰器,负责把 Toast 展示到页面顶部。 +private struct GlobalToastOverlay: ViewModifier { + @Environment(ToastCenter.self) private var toastCenter + + func body(content: Content) -> some View { + content + .overlay(alignment: .top) { + if let message = toastCenter.message { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 14, weight: .semibold)) + + Text(message) + .font(.system(size: 13, weight: .medium)) + .lineLimit(2) + + Spacer(minLength: 0) + + Button("关闭") { + toastCenter.dismiss() + } + .font(.system(size: 13, weight: .semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + .padding(.top, 10) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.18), value: toastCenter.message) + } +} + +extension View { + /// 给任意根视图追加全局 Toast 展示能力。 + func globalToastOverlay() -> some View { + modifier(GlobalToastOverlay()) + } +} diff --git a/suixinkan/App/suixinkanApp.swift b/suixinkan/App/suixinkanApp.swift new file mode 100644 index 0000000..a18e9a5 --- /dev/null +++ b/suixinkan/App/suixinkanApp.swift @@ -0,0 +1,18 @@ +// +// suixinkanApp.swift +// suixinkan +// +// Created by hanqiu on 2026/6/18. +// + +import SwiftUI + +/// 应用入口实体,负责把 SwiftUI 根视图挂载到主窗口。 +@main +struct suixinkanApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + } +} diff --git a/suixinkan/Assets.xcassets/AccentColor.colorset/Contents.json b/suixinkan/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/suixinkan/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json b/suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/Contents.json b/suixinkan/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/LoginBackground.imageset/Contents.json b/suixinkan/Assets.xcassets/LoginBackground.imageset/Contents.json new file mode 100644 index 0000000..fd5d0d0 --- /dev/null +++ b/suixinkan/Assets.xcassets/LoginBackground.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "img_login_bg.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/LoginBackground.imageset/img_login_bg.png b/suixinkan/Assets.xcassets/LoginBackground.imageset/img_login_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..1def1eda816d252f1abfdb52439d9fe40ef7fff9 GIT binary patch literal 78554 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 literal 0 HcmV?d00001 diff --git a/suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/Contents.json b/suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/Contents.json new file mode 100644 index 0000000..4e75f55 --- /dev/null +++ b/suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon_cb_privacy_selected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/icon_cb_privacy_selected.png b/suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/icon_cb_privacy_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..679c06eacba63341aa67c2acc3a99f085320b800 GIT binary patch literal 1888 zcmV-m2cP(fP)6257{`CJrBKB}MMM-a9*Lq7@lO0AAJl+&M2$v`7an*}6X_wK0j;74xb32H1w06T z5F#NaA_N5z42d@e4e^LdBA5Vj9}#T3x-FJ6sEAj2~?>q0zJjZ`$-e+dE zjc|-!{}cPK4Q%m7%zMWeUqP)ui)w!(HU0%*nXpdyQ)m|c7B&k%3Gb`l6ychL&Uk+! zb-rlv6mF=_mr>;}q?V^C;T=3{aD1o{eonYvxJ2kHn6FaZ2MFg1x2xYcVP!(6kp=AI zaoM_31iPudATek88>sPv6ik!|M&uo0V+ z^w$UcniAJt*#c>Ym@-k7AF3%ZT}H}is<7BqY>^wMcCO^%3Ylbs8mfu8UqB1$7=Jzyrl_t^QZk_k z?Dt`$6KgN1!zw8^$7OV94pm&hbhn#wYJgF~S|MG&QpJPRJG(@x1|iHWQTL2; zAhji(*p}4BoXQTTeuG40m5%JgvD3lp?srkDR9&T7*9q0c-1q87qP^OWQQ1bfkYphf z0r@WVDV^BApJ-~Y+{*nlae$xI;9!d3c5qb*mF~clZ*u564lPcrNBI&#$TG zg2X(95Hq`HDrmiP{$YDaAeqwbp+BdpeUgf&3B{IAQVj-)MX;B?`|ndb2LQS8HOn$ifVD z46NjO?uufI@+9h96KPj#V%DMF{YV^3%dHfwxr?9?uNWI#n_0YVVitLXg?cwuO4-|E zmx5PRu!2LpnJKQ!#Om2WGgCya>rF|=HqEFgGuauQ7|M__w}v>juM4aZbndk;5tGGc zF=mJ*^N1xCVs4=Z{wY0%-yln=$g)KOOjaMo zdlL~>U_Q-v2uIZ70>y? zJo&Ug`PgPu#e&3Ufqi>5Q&xr&b2CVs=aivU5k~SLmSWyk{a%bF&TXo#6_yHZ?gUid zo|w)D>M%jJZlau8Z1&Cxh2_GF>UUE@rIYMcr8Sc9a+K9L!P6F=_ ziL{ae$7pn-u|`Lfv>ytjLc-V|nWoi*jvEO9n=>uT-sAVV?|XOO`?3q~yFcX0c=!3< zbIxd%Jb)(Dj)2j_dpwTKxyf_)pQ|ZxJ>LJB9xU-6dH5 z1?4viw}f>U`BTHTZGOn&85%<^{!Cg~M4I(@p1|=t_3%>RbHXHHoM65TmOowPFA7V; zx&d3mwjC^_*-xd-*Xi1z#2gLWfDy|0@5{i$!gAsBLX%LdG^&BM{T?YE;2?LYCkJ9j zW{NTZV%}*8jnkd|woF*>T&wg$LcP!_ye2%Sp2*_)8i$xn!29T6M2J9n&l#$7rRw}x z>2ty!!P<^gI#GJN_I&8$8h;ANr!LeYwG(S*x*V)0MM3F!!K4Gvqrwse^##(*AnO*9 zck5W=c2RJza<}UWVfnm!k(~R9>U@(!oJ+u$@Z2Cn*7HVCSw_i6o_BE%8P%b-qc&pA z{xVW*lnZ-yqUT_Tf|^Gs^Vm&+tb2x)u0wUIlQpiBf_s7-t3~i~HM&^XDg;73o;f%k zQ14x)a@A5Dv8#N&#{A_X^+Q>HuaFjA7gmzy0`l%b?sR+E=(?pFm^kMtxP9c@W2CuA zMzVtWP{IWHzFRZyl~^pMIR>gDHkvDR*H!>y01JLA0beEOuBVpXQRAw_Y?^JDC%@b9 z`hy0Ym+@}CT#b!m6SLvx$;nuwpy!xpMQT2IHizZmJZ1TfdTN1O><~;EMRavF!}2J^ zG!td~c`}@rMY~&e-?poKF$MP$Hgt4hSjBv-a(@ebdbZ1#jg~Pg-yw1{beElUeMW8}T#>Uiobr`}4>;o0|JHHf&1s>G_K6KP8xqBgJA`(5t|TN1}gXBe1bNON5-T zS523*l9!YoAIbAF0k_GyO(-^b%~rYJnX+$U8Q-Kka=VP!OV_5|b>!S;Y-mV%T*Q1A z+@tcnPB5ucj5leqaHbV1aU`+gI_)M^93`0iku=LKlL!=J*-?sRydIOQOZixGB(Vcb z#$oRft7xl$W!Us8sb`#O$*n#WXdr6}S=}*EoLGGKoSb_HNro zF{mabmtdbw%0v<;wx7|u@Cm^dqn+yJ$I0_wm6IiV*bn?x=oIRO^J(TJr9BcSR*y}e z=asG=z_SZ`XiJL3B(`)*u|@Ih6HLybj*~4DAx^BD_v=EVVDcJ=dD=3GM2f#CuIQcG zM8F3qJL1F~pCme*NUd?!)sZGysZAn|$9jS)k6p?*G5CmdMe+Qpm`U(C;Q6xXrj+HW zB=*ssi=HQY@=48fX%6U(Eqa7PO(n6hq1H~GH!Mjs>WH2(3)7~3OGETe?2TBXuyW!~ z@fdqMMS)fllj_8fN@DwZXpJJ;s3Eb(s921>u>+OFc1u^3!?8w1UDD2uXrXSA9hF$S z)d*3~q%ckL$zjXCD8fHS8A~gBEczUK?Q)%FLyv19$BQ;($hC$L*E8Baj(O2jltLS0B|LMfzn()_EG50iM09mYOU zqVKP=%f zGNc&G$4kN3Lj6|S++iw?zKL0{1>CB-QG*5AQl4G8VWR7Ao!ErY;%CX>0>R?$BH#w{ zuK3tUSuR$gyxf~lalZ<-L23Kt<4AEM8BUe%_{d^Tlj9|LP7%EFn>oaNO8b=liRIW! zz)H2UEoCK~ujlRO`i<(XUmUmk@pQ96gr+G5C$uYn8+Q_0A6k$R`X@&CL);but^;hl zD=mXlRep#?{=?YNV3-f{(dHkuTp6n)82h?S}r-2J*c3ss61 z$WsVdM=PI|Zg?fbEK;l&A)DUKSG)2pSWH$r3XV<88vl>`WYDJA4y6vyG^saeYk9@6 zyb5eeZ}BTc>SyvZT}TW2<@b6Hat~D;)e*B_{IhN?0awYU{kww_Y#Y2)V}FwfUP!Ya zJE-AlOE>%1hm5~OHf@)8v7DF>o4o!l-`^td?!{u!g=0x|#3Cy;1U#3j*X#w}CAGD; zJNghQ?xlr=A~+LUdFx_TV!hMq&!vfGMzR4Xz?4#3d)+)jz+B9CNm)%|fmKtlw@#jC z_&-<3Sq88dq!NV$a}?C=r1%9RRHxPdNIm&=GX8XtorwL~Z)o-tMSPO<3pB&7qMI#v zex^hN8wG!tevQgAF&{an*NuEiQHuyPGY-Puc}{jTt9pt9Pd3 z`Lr%A6MiYYDCn+Zt8atlcS-k%uq>?Wm>IUsvWh_xlFd*CPb@T}Q=1F8!|i94dzrlZ zy#l;hm@AwiyhlivVEM_)UnJZd*3G(qgl&tTO$@~`bi{@tnjy4t!V-HE00960Lq6{X h00006Nkl4Tx02q~hkiAR8U=W9&YQ+x}g?4ao=upr>rHG1a+o6aKr7BomOw+Uq{dh?t zevG1X`nOBr0@(W(Y$+4e_U{&S8H6h1XRv6OjQV z0000uWmrjOO-%qQ0000800D<-00aO40096102%-Q00002paK8{000010000;paTE| z000010000;00000v-xDz000WCNkl+ah#8Oax~(GrNjpnW$T0=7dHV(}-HPFemFkp-@YkEQ_1lbZa5b zsx!7_tvI)KwWZzfU!SKuANTos-?z-v_a-OzdCooO+4S6{a)R%5sA`q?sddC|_;K2&~CK%sr-6Z~#EwuJ^iah@Ce)Y#bgdE{4Ns|=}G z&_rkgRA7$|F}s|OS4#}bWfaA|4j z16bM)wR69;c8`! z_~Y1{=CnCV&o8m~SN8VyzF1yf{-1Q}h&6-k3es`6rAwD;CVdTRvF(jwJ|DMxrm(Q^ zhsTZ`o1IQQi2zbXA8>bbVsQyGwvq+>5DWO1odrpr6SO`Nyz1jc2VlsidbkSf1gMrBkEaxM8Jss3B=SnDXyU`IyHTn7ZI~OflWZG;f z_NUkt)z#HaC@Lzt7ZV#GUrgjT{r*g1Er=aVnrzs-+S=NJ>C>mr2BiDorQ|Zq!hZCh z1fuJnG zSehJZEkpntiOy~zriP1-9653k+xTsCxH?KUpuCSZZ6W;F8&E#X4g`pbb6Ai|kD1;1k$ggM1#8C|T{<07!>;*5TNf z0$mJ0FGl(fC?nvgW;!0~ZVMMK>;xdKEgjGncCJR=e+menlD;Wn^KSU3O=VF$t3BK3 z>&zh8K!8yACbTq2F@mpC_3t5Vt=<%Rb|V)0`?+)HwxaX`q%+l58QnvQoxFzhPFXA2 z0rGxRc?{lS2BI=Z&gfz^=F_0th*IKR!m8*D`B$(poys4yM#|L&Iuz+{>@*E1l|X9e z>*%apvSdjMxyv1hls`^AzePsDWvc0O#B3T=SMtpurt>U1q$`e8!BGXTW8;syOe5Fm zw2A3V7HEN2C2}`p^s%4`5+J(XsKOmS;G<>e=Quc7ZVygELBsHg(^j8`z> zdmZ2myBD20i0^9g4bvH)zW%R#=p4>=_@O8|TPk8O<_`rhx3ok$6dkJm)l2^wC z5C~q1z#1Br)_n?%)u<0A8b6@ph9YVhzPN?DbnbePnX#7o?JL(|T^c-u{!8Q)Fmwwr z0Xus3CG7az!zUEZP@mTvJ$h7|(@1gwL?xf3WbsI8iVtkf&0bYHs&L`2UcTN15?P?T zu?@e9%&@xwmhqTDdArGWLf$n5kggf(0*I@DZ)319p;4cBP~1Z2uJj3?XT)_k;qrKd?~ech?)0*Fxvj0c=bQP0?M*|p_^_>S^8?slUs@62mzYP3dWJh?hT2lVwK z(S3=jMQpntHSys^x&dv7bT^ajxpU{vGVv4g&QkwAr;ZCC7E7%ie{%|?@{B9_woBQe z@~30k3w5^u8|lPAw#n!Cp~fHV7v(O1T3T8(!n#;bYZd{}XJy=*7=Su>v21H51?_HL zyLw0d3nqR7-oKE2YQcg9%14q5piP@L>8|7%N?#d~NZ|p1uJ>-)GQ$@AXR=>G=ky+x z1QIfVyPK^~U6Xk>ZvVDSjQm;Zd(JE80!W2t&YU@o;E%n63ExW?{j0n>#6#}@-VXS0 zddouIXwp_Dn^cod0ek6d4X~odU+Qm;?yi_L6DK3zU(ZDFK3JVl^KP?^(BQ zoo}}2;7R!-=w1fJvX9Nsvzuj8%R|!J2eox0;Os}&mwYqGmY_kp`+eDdPm)vNc;wd> z7Z)2BYkea_=8MQY2KC0VjeMxP$tHG^es|O0{f612WyP2R0qW@J(Bba~kZ;-IuGcPR%^a6#hTQEegL4xR_^(~YJuiVXNOJo94Wqmj zLp8h(cy!~wR!yBcb(LvX3p1dz8{kwT)8>`SfV*j?Zi@m(^@ai@0iCP(icrPl0Z2E6 zNz+V5d^e;8KT!gRl&?xc+^rY8_d{CHBU7l0IklMqm(t0fLFE|NBAiOPn+0)CS65ei z5|@*^QSU=kIN4h%{3N^EyXb$9a6@ZetP~p{S+hXWY`Pgr<T;w9{6m~vqmtn*d>^%yZViu?P1$k!1 zg$9$UG$Q30WLGAawcq8k_W#p;ubhgQ~ z@=Uo~$}Z+@z2YT6(GGVVuBZG2l4u^Hs-4nOG5B}y$O=FzXn zZlbZPDbL7#x1*j{vAY-hwdC6E?ty$i>`K?&Mg<^8Cx#!bGu;w8vy#p%gmfiQ1QkGv zr}81SMY)n1$tImup_9HiwEcUvj-_J$h_MqPoIQ7Tu46-|^Gb4bE z+)N4_s45OPvSG&^w!JZ#1IR&U&Nyfu9}<2P7o0H(Mz&K~1hNQZ5y&F&AwuAPMgks( TPVur|00000NkvXXu0mjf0@eP; literal 0 HcmV?d00001 diff --git a/suixinkan/Assets.xcassets/LoginPwdVisible.imageset/Contents.json b/suixinkan/Assets.xcassets/LoginPwdVisible.imageset/Contents.json new file mode 100644 index 0000000..b433e22 --- /dev/null +++ b/suixinkan/Assets.xcassets/LoginPwdVisible.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon_pwd_visible.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/LoginPwdVisible.imageset/icon_pwd_visible.png b/suixinkan/Assets.xcassets/LoginPwdVisible.imageset/icon_pwd_visible.png new file mode 100644 index 0000000000000000000000000000000000000000..820554217542b88e3a07fb658f3ad4b55a0ce0a0 GIT binary patch literal 2856 zcmZ`*X*d)L7oM@jSSut;2HhBvWrSoOvJYm6D}-xFmhAgllc@=DBQ#mE@5$K5SO$q9 zG8i!=T#YT0EHxOP`{(<9edm4NbKd8?&vSmfzs^$|xCt*v6a)YOc+E_W5U11hUvhGs zcKa=y2><}p^o7A}%wRCEO~?apUwvBJFUXIw~CRlv&n@%8hzxfn|?#VP8ZOW zt@e^u+~C2OBJ(-3(R~deVT*7mSCm;M{?@tE?kt>+ZOo!~k?aetU9SD6?#JBb{`0HY z*QbalHB$AIh&{Il9BJ=oF_XHVM1wp-i)rC=!u#PZ*bX%XxH z^uH$Vx91c9z~y6RWMCHs+`@AC+Zzj};6EA8mw_-Gytd}f$|~=TGRl&7*^Q0agm`C- z{_yaq#ov0Dbk^CFHy9>JmE)ZJL*DRSlJqNqzm`O~QF2QxO>%8vcaIV}TRZUtd?$tD z#m%81Lg?y99`xjO-VNvhp|)0YfV2PqK-`_v^PojXOe1ilWHi~u#pMwWhqE3Y8oJSf z!~HahiZ`ZfLHt$-5)83f7?b}OepfeQ)A2{F;vPu_Or)qC zr;vVpQJmbF;m28QhB-g{S3iTUvrOpHkN}stnVGg|B8p3q9;>#;sjuKrKce%L4Gdcw z@!Booksl7SP5{5z=}L!;)BiuLfKUmBfjy3=JswxV3T^aH&kA7T7Y-8>p3Pxfl!>tz73Y)7` zH?z3e1zeG+H@JJ7w@s5>W=hW$HJo}b}+4?oM+zT5U8Jru_;gx^^g_cLKuCTt^&(W(-&iBMd=W|aF*IqwriKfG7?#g$wj7%r!fVqMNN$57X72BsEB)KCf2WKuyJ{)ik}PufmSOY zn8i^jSrV=|Z!2K$mlq$7F{vhr@+bQxFJzWEA=Xd^yqnwaFMRhz9RDMA;?)1+Ia&UsdYmJd~(^%(QIYQ%(eKw_IkB6?9|J2c3G*JQKY@=-ba=f@Cft0p z6_Jpuy+>*%YLQ$ASp+y%z?xidpqmffcW71K!4b}BAvKll^^fZMLRo1P>9}m+aQxMP z>rimb)=@_EjraY=&xHR>hYBYJO(6vkriGG-cH`G@|7PV>o};zSTUy;0#ZdXup0U1w+i@1^K!CqOZ42eeBWg>bOddN&qo<4o+G#GD&JYHBdB#Li zwsI~z-~dL;kv1aN!JE^hOrioxlBy_pJfKP}7yWsqXT&6KpZ$@^-y5grz!C@6(}#%( zlqu85MgR({3pWdm^zFY;=XJ>*s=_Pib@Ln^+f`SFo$b|^MSu4MKDkbVnv1`)XbtPR zK%ER19shL*v@bnZ6Ryy||3M|AiXY$Y##^oOGsX!}T~kBmR6uewcNt~Yp=x&R z;g_4s6uVVi;C%^*ByV(x_n3zU{#U_QbYL*xAeiAXM5afL( zvz27yAU2^u&CaRl;Dd8zWUXV0>*@k7f&Sr5&mUT7uweQ`1CN@c(w0%%(%Ynop1lpq zTORDH%w`*tZcqQR{ru%p??Kd6#=N|CMJ_~&JTn5NN4j*I?v7?m<#fCf>Hfy8V#Eq*`m=Ln-Wi$I_?#(Ba9RowSJK$ zCW2b--R1VHdg8BMihzf(fsYxljs&a&K7Y}-P;N7ts`f?8N8$? z$7PgC1gpfvtdeoQ$;9@|P)nsx5e#xiTM&;=J!U3&`WW=tqX7 z&Zu>+PpVCGBfTnR!S~P;F26ua*xv>%bTF?c5BPey1RC8bm)u*XZK7{`;dV8LIir}t zz6%5p`oea!)!8Pz&kxJ#mj@<{=l2aD6FmxgqsUew^@>`XId;f=3KGAF*iAILIoDQR z#V>Xz8UkNE;M5=DrOj)S(~6;7J)*$1`-X|e;|Cf8$Q|v^9+$iXl^{z2k5{A=ZDe+# z1?@um_uDiNuOi8ugn}vufO+72Zpv!mpYS4oaF|+YY`$R+`G<*t3p9xdkK2{)j2VvT o-PqH^jeEcLVEcbazskmBw~sBonv|;f?=K86Glm=08@fIG56@Om8~^|S literal 0 HcmV?d00001 diff --git a/suixinkan/Core/Core.md b/suixinkan/Core/Core.md new file mode 100644 index 0000000..aa60590 --- /dev/null +++ b/suixinkan/Core/Core.md @@ -0,0 +1,65 @@ +# Core 模块业务逻辑 + +## 模块职责 + +Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存储和通用设计常量。业务页面不应直接重复实现这些能力。 + +主要子模块: +- `Networking`:统一 API 请求、响应解析、错误处理和 token 注入。 +- `Storage`:统一登录 token、账号快照和 App 偏好的本地存储。 +- `Design`:统一颜色、字号、间距、控件尺寸和圆角。 + +## Networking + +`APIClient` 是统一网络客户端,负责: +- 根据 `APIRequest` 生成 `URLRequest`。 +- 注入公共 Header:`Content-Type`、`Accept`、App 版本和系统类型。 +- 通过 token provider 或 `tokenOverride` 注入登录 token。 +- 发送请求并处理 URLSession 错误。 +- 校验 HTTP 状态码。 +- 解码后端统一 `APIEnvelope`。 +- 将 HTTP 错误、业务错误、解析错误转成 `APIError`。 + +业务模块只应该封装自己的 API 类,例如 `AuthAPI`、`ProfileAPI`,然后调用 `APIClient.send`。页面和 ViewModel 不应直接拼接 URL 或处理原始响应体。 + +### 响应约定 + +后端响应通过 `APIEnvelope` 解包: +- `code` 表示业务状态。 +- `msg` 表示业务提示。 +- `data` 是真正业务数据。 + +`APIEnvelope.isSuccess` 为 false 时,`APIClient` 抛出 `APIError.serverCode`。 + +### token 失效判断 + +`APIError.isAuthenticationExpired` 用于判断是否需要清空登录态: +- HTTP 401 / 403 视为登录失效。 +- 业务码 `200001` 视为登录失效。 +- 错误文案包含 token、过期、登录失效、重新登录、unauthorized、验证失败等关键词时视为登录失效。 + +## Storage + +本地缓存按安全级别拆分: +- `SessionTokenStore`:使用 Keychain 保存正式 token。 +- `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。 +- `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。 + +缓存边界: +- 正式 token 只放 Keychain。 +- 临时 token 只放内存。 +- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。 +- 头像图片缓存交给 Kingfisher,Core 不保存图片 Data。 + +`AccountSnapshot` 保存可重建的账号展示和业务上下文: +- `AccountProfile` +- 账号类型和业务账号 ID +- 当前角色 ID +- 景区作用域和门店作用域 +- 当前景区 ID 和当前门店 ID + +## Design + +`AppDesign` 管理跨页面颜色。`AppMetrics` 管理常用字号、间距、控件尺寸、行距和圆角。 + +新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。 diff --git a/suixinkan/Core/Design/AppDesign.swift b/suixinkan/Core/Design/AppDesign.swift new file mode 100644 index 0000000..3ae9221 --- /dev/null +++ b/suixinkan/Core/Design/AppDesign.swift @@ -0,0 +1,29 @@ +// +// AppDesign.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import SwiftUI + +/// 应用通用颜色定义,集中管理跨页面复用的设计色值。 +enum AppDesign { + static let primary = Color(hex: 0x0073FF) + static let primarySoft = Color(hex: 0xEFF6FF) + static let textPrimary = Color(hex: 0x1F2937) + static let textSecondary = Color(hex: 0x6B7280) +} + +extension Color { + /// 通过 0xRRGGBB 和透明度创建 SwiftUI Color。 + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xff) / 255.0, + green: Double((hex >> 8) & 0xff) / 255.0, + blue: Double(hex & 0xff) / 255.0, + opacity: alpha + ) + } +} diff --git a/suixinkan/Core/Design/AppMetrics.swift b/suixinkan/Core/Design/AppMetrics.swift new file mode 100644 index 0000000..ec25aba --- /dev/null +++ b/suixinkan/Core/Design/AppMetrics.swift @@ -0,0 +1,67 @@ +// +// AppMetrics.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import CoreGraphics + +/// App 内通用尺寸定义。这里只放跨页面复用的字号和间距,具体业务页面的特殊尺寸仍留在页面本地。 +final class AppMetrics { + private init() {} + + /// 字体尺寸实体,统一维护 App 内常用字号。 + enum FontSize { + static let caption: CGFloat = 12 + static let footnote: CGFloat = 13 + static let subheadline: CGFloat = 14 + static let body: CGFloat = 16 + static let callout: CGFloat = 17 + static let title3: CGFloat = 18 + static let title2: CGFloat = 20 + static let title: CGFloat = 24 + static let largeTitle: CGFloat = 30 + } + + /// 间距实体,统一维护页面边距和组件间距。 + enum Spacing { + static let xxxSmall: CGFloat = 2 + static let xxSmall: CGFloat = 4 + static let xSmall: CGFloat = 8 + static let small: CGFloat = 12 + static let medium: CGFloat = 16 + static let mediumLarge: CGFloat = 18 + static let large: CGFloat = 20 + static let sheet: CGFloat = 22 + static let xLarge: CGFloat = 24 + static let xxLarge: CGFloat = 30 + static let pageHorizontal: CGFloat = 16 + static let pageVertical: CGFloat = 24 + } + + /// 控件尺寸实体,统一维护按钮、输入框和图标点击区尺寸。 + enum ControlSize { + static let smallIcon: CGFloat = 16 + static let checkboxIcon: CGFloat = 20 + static let passwordIcon: CGFloat = 22 + static let progressWidth: CGFloat = 22 + static let checkboxTapArea: CGFloat = 28 + static let iconTapArea: CGFloat = 32 + static let sheetButtonHeight: CGFloat = 48 + static let primaryButtonHeight: CGFloat = 50 + static let inputHeight: CGFloat = 52 + } + + /// 行距实体,统一维护多行文本的常用行间距。 + enum LineSpacing { + static let title: CGFloat = 3 + } + + /// 圆角尺寸实体,统一维护输入框、按钮和卡片圆角。 + enum CornerRadius { + static let input: CGFloat = 12 + static let button: CGFloat = 12 + static let card: CGFloat = 16 + } +} diff --git a/suixinkan/Core/Networking/APIClient.swift b/suixinkan/Core/Networking/APIClient.swift new file mode 100644 index 0000000..e3da623 --- /dev/null +++ b/suixinkan/Core/Networking/APIClient.swift @@ -0,0 +1,257 @@ +// +// APIClient.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation +import Observation + +/// URLSession 抽象协议,用于让网络客户端支持测试替身。 +protocol URLSessionProtocol { + /// 发起 URLRequest 并返回原始数据和响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: URLSessionProtocol {} + +@MainActor +@Observable +/// 统一网络请求客户端,负责构造请求、注入 token、校验响应和解析业务 Envelope。 +final class APIClient { + @ObservationIgnored private let session: URLSessionProtocol + @ObservationIgnored private let encoder: JSONEncoder + @ObservationIgnored private let decoder: JSONDecoder + @ObservationIgnored private var authTokenProvider: (() -> String?)? + + private let environment: APIEnvironment + private let appVersion: String + private let osType: String + + /// 初始化网络客户端及其编码、解码和环境配置。 + init( + environment: APIEnvironment = .current, + session: URLSessionProtocol = APIClient.defaultSession, + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder(), + appVersion: String = AppClientInfo.appVersion(), + osType: String = AppClientInfo.osType + ) { + self.environment = environment + self.session = session + self.encoder = encoder + self.decoder = decoder + self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0" + self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType + } + + nonisolated private static let defaultSession: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 12 + configuration.timeoutIntervalForResource = 20 + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration) + }() + + /// 绑定 token 提供者,让业务 API 不需要直接持有登录状态。 + func bindAuthTokenProvider(_ provider: @escaping () -> String?) { + authTokenProvider = provider + } + + /// 发送强类型 APIRequest,并返回解包后的业务数据。 + func send( + _ apiRequest: APIRequest, + tokenOverride: String? = nil + ) async throws -> Response { + let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride) + logRequest(request) + + let data: Data + let response: URLResponse + do { + (data, 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: data) + try validateHTTPResponse(response, data: data) + return try decodeEnvelope(Response.self, from: data) + } + + /// 将业务请求模型转换为 URLRequest,并注入公共 Header、token 和请求体。 + private func makeURLRequest( + _ apiRequest: APIRequest, + tokenOverride: String? + ) throws -> URLRequest { + let path = apiRequest.path.hasPrefix("/") ? apiRequest.path : "/" + apiRequest.path + guard var components = URLComponents( + string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path + ) else { + throw APIError.invalidURL + } + + if !apiRequest.queryItems.isEmpty { + components.queryItems = apiRequest.queryItems + } + + guard let url = components.url else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = apiRequest.method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION") + request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE") + + apiRequest.headers.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + + let token = tokenOverride ?? authTokenProvider?() + if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { + request.setValue(token, forHTTPHeaderField: "token") + } + + if let body = apiRequest.body { + request.httpBody = try encoder.encode(body) + } + + return request + } + + /// 校验 HTTP 层响应状态码,非 2xx 时提取错误信息。 + private func validateHTTPResponse(_ response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard 200 ..< 300 ~= httpResponse.statusCode else { + throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data)) + } + } + + /// 解码后端统一 Envelope,并返回内部 data 数据。 + private func decodeEnvelope(_ responseType: Response.Type, from data: Data) throws -> Response { + let envelope: APIEnvelope + do { + envelope = try decoder.decode(APIEnvelope.self, from: data) + } catch { + throw APIError.decodeFailed(error.localizedDescription) + } + + guard envelope.isSuccess else { + throw APIError.serverCode(envelope.code, envelope.msg ?? "业务请求失败") + } + + if responseType == EmptyPayload.self { + return EmptyPayload() as! Response + } + + guard let payload = envelope.data else { + throw APIError.emptyData + } + + return payload + } + + /// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。 + private func parseHTTPErrorMessage(data: Data) -> String { + if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data) { + if let msg = envelope.msg?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty { + return msg + } + if let message = envelope.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { + return message + } + if let error = envelope.error?.trimmingCharacters(in: .whitespacesAndNewlines), !error.isEmpty { + return error + } + } + + if let plainText = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !plainText.isEmpty { + return plainText.count > 120 ? String(plainText.prefix(120)) + "..." : plainText + } + + return "服务端返回错误" + } + + /// 将 URLError 转换成中文网络错误提示。 + private func networkErrorMessage(for error: URLError) -> String { + switch error.code { + case .timedOut: + "请求超时,请稍后重试" + case .notConnectedToInternet: + "网络不可用,请检查网络连接" + case .networkConnectionLost: + "网络连接中断,请重试" + case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed: + "无法连接服务器,请稍后重试" + default: + error.localizedDescription + } + } + + /// 在 Debug 环境打印请求信息。 + private func logRequest(_ request: URLRequest) { + #if DEBUG + let method = request.httpMethod ?? "REQUEST" + let url = request.url?.absoluteString ?? "" + print("[API][Request] \(method) \(url)") + #endif + } + + /// 在 Debug 环境打印响应状态和响应体。 + private func logResponse(for request: URLRequest, response: URLResponse, data: Data) { + #if DEBUG + let method = request.httpMethod ?? "REQUEST" + let url = request.url?.absoluteString ?? "" + let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown" + let body = Self.debugResponseBody(from: data) + print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)") + #endif + } + + /// 在 Debug 环境打印被取消的请求信息。 + private func logCancelled(for request: URLRequest, reason: String) { + #if DEBUG + let method = request.httpMethod ?? "REQUEST" + let url = request.url?.absoluteString ?? "" + print("[API][Cancelled] \(method) \(url) reason=\(reason)") + #endif + } + + #if DEBUG + /// 将响应体格式化为便于调试阅读的字符串。 + private static func debugResponseBody(from data: Data) -> String { + guard !data.isEmpty else { return "" } + if + let object = try? JSONSerialization.jsonObject(with: data), + let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), + let prettyJSON = String(data: prettyData, encoding: .utf8) { + return prettyJSON + } + return String(data: data, encoding: .utf8) ?? "" + } + #endif +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan/Core/Networking/APIEnvelope.swift b/suixinkan/Core/Networking/APIEnvelope.swift new file mode 100644 index 0000000..1273a7f --- /dev/null +++ b/suixinkan/Core/Networking/APIEnvelope.swift @@ -0,0 +1,62 @@ +// +// APIEnvelope.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 后端统一响应包裹实体,承载业务 code、msg 和真实 data。 +struct APIEnvelope: Decodable { + let data: T? + let code: Int + let msg: String? + + /// 判断后端业务 code 是否代表成功。 + var isSuccess: Bool { + code == 100000 + } + + /// 后端统一 Envelope 的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case data + case code + case msg + } + + /// 自定义解码逻辑,兼容成功响应、空 data 和 EmptyPayload。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0 + msg = try container.decodeIfPresent(String.self, forKey: .msg) + + guard code == 100000 else { + data = nil + return + } + + guard container.contains(.data), try !container.decodeNil(forKey: .data) else { + data = nil + return + } + + if T.self == EmptyPayload.self { + data = EmptyPayload() as? T + return + } + + data = try container.decode(T.self, forKey: .data) + } +} + +/// 空响应实体,用于表示接口成功但不返回业务 data。 +struct EmptyPayload: Codable {} + +/// 错误响应实体,用于从 HTTP 错误体中提取服务端文案。 +struct ErrorEnvelope: Decodable { + let code: Int? + let msg: String? + let message: String? + let error: String? +} diff --git a/suixinkan/Core/Networking/APIEnvironment.swift b/suixinkan/Core/Networking/APIEnvironment.swift new file mode 100644 index 0000000..4f2cd15 --- /dev/null +++ b/suixinkan/Core/Networking/APIEnvironment.swift @@ -0,0 +1,62 @@ +// +// APIEnvironment.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 网络环境实体,集中描述 HTTP 和 WebSocket 的服务地址。 +struct APIEnvironment: Equatable { + let baseURL: URL + let webSocketURL: URL + + nonisolated static let production = APIEnvironment( + baseURL: URL(string: "https://api.zhifly.cn")!, + webSocketURL: URL(string: "wss://api.zhifly.cn/wss")! + ) + + nonisolated static let testing = APIEnvironment( + baseURL: URL(string: "https://api-test.zhifly.cn")!, + webSocketURL: URL(string: "wss://api-test.zhifly.cn/wss")! + ) + + nonisolated static var current: APIEnvironment { + #if DEBUG + .testing + #else + .production + #endif + } +} + +/// 客户端信息工具,负责提供网络请求所需的 App 版本和系统类型。 +enum AppClientInfo { + nonisolated static let osType = "iOS" + + /// 生成后端要求的 App 版本号,版本号不足三段时用 build 号补齐。 + nonisolated static func appVersion(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String { + let version = (infoDictionary?["CFBundleShortVersionString"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? "1.0.0" + let build = (infoDictionary?["CFBundleVersion"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + let versionParts = version.split(separator: ".", omittingEmptySubsequences: false) + + if versionParts.count >= 3 { + return version + } + if versionParts.count == 2, let build { + return "\(version).\(build)" + } + return version + } +} + +private extension String { + nonisolated var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan/Core/Networking/APIError.swift b/suixinkan/Core/Networking/APIError.swift new file mode 100644 index 0000000..2b96645 --- /dev/null +++ b/suixinkan/Core/Networking/APIError.swift @@ -0,0 +1,61 @@ +// +// APIError.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 网络层错误实体,统一转换 URL、HTTP、业务码、解析和网络异常。 +enum APIError: Error, LocalizedError { + case invalidURL + case invalidResponse + case httpStatus(Int, String) + case serverCode(Int, String) + case emptyData + case decodeFailed(String) + case networkFailed(String) + + var errorDescription: String? { + switch self { + case .invalidURL: + "请求地址无效" + case .invalidResponse: + "服务响应异常" + case let .httpStatus(statusCode, message): + "请求失败(HTTP \(statusCode)):\(message)" + case .serverCode(_, let message): + message + case .emptyData: + "接口返回数据为空" + case .decodeFailed(let message): + "数据解析失败:\(message)" + case .networkFailed(let message): + "网络请求失败:\(message)" + } + } +} + +extension APIError { + /// 判断任意错误是否代表登录凭证失效。 + static func isAuthenticationExpired(_ error: Error) -> Bool { + guard let apiError = error as? APIError else { return false } + switch apiError { + case let .httpStatus(statusCode, _): + return statusCode == 401 || statusCode == 403 + case let .serverCode(code, message): + let text = message.lowercased() + return code == 200001 + || text.contains("token") + || text.contains("过期") + || text.contains("登录失效") + || text.contains("重新登录") + || text.contains("unauthorized") + || text.contains("验证失败") + || text.contains("驗證失敗") + default: + return false + } + } +} diff --git a/suixinkan/Core/Networking/APIRequest.swift b/suixinkan/Core/Networking/APIRequest.swift new file mode 100644 index 0000000..858bc3d --- /dev/null +++ b/suixinkan/Core/Networking/APIRequest.swift @@ -0,0 +1,55 @@ +// +// APIRequest.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// HTTP 方法实体,限制网络层支持的请求方法。 +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} + +/// 强类型 API 请求实体,描述一次请求的方法、路径、参数、Header 和响应类型。 +struct APIRequest { + var method: HTTPMethod + var path: String + var queryItems: [URLQueryItem] + var headers: [String: String] + var body: AnyEncodable? + + /// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。 + init( + method: HTTPMethod, + path: String, + queryItems: [URLQueryItem] = [], + headers: [String: String] = [:], + body: Body? = Optional.none + ) { + self.method = method + self.path = path + self.queryItems = queryItems + self.headers = headers + self.body = body.map(AnyEncodable.init) + } +} + +/// Encodable 类型擦除包装器,用于在 APIRequest 中保存任意请求体。 +struct AnyEncodable: Encodable { + private let encodeValue: (Encoder) throws -> Void + + /// 包装任意 Encodable 请求体。 + nonisolated init(_ value: Value) { + encodeValue = value.encode(to:) + } + + /// 将被包装的请求体编码到目标 Encoder。 + nonisolated func encode(to encoder: Encoder) throws { + try encodeValue(encoder) + } +} diff --git a/suixinkan/Core/Networking/ListPayload.swift b/suixinkan/Core/Networking/ListPayload.swift new file mode 100644 index 0000000..65cc569 --- /dev/null +++ b/suixinkan/Core/Networking/ListPayload.swift @@ -0,0 +1,55 @@ +// +// ListPayload.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 列表响应实体,表示后端常见的 total + list 分页或全量列表结构。 +struct ListPayload: Decodable { + let total: Int + let list: [T] + + /// 列表响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case total + case list + } + + /// 创建列表响应,主要用于测试和本地兜底数据。 + init(total: Int, list: [T]) { + self.total = total + self.list = list + } + + /// 自定义解码,兼容 total 是字符串或缺失的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + list = try container.decodeIfPresent([T].self, forKey: .list) ?? [] + } +} + +private extension KeyedDecodingContainer { + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan/Core/Networking/Networking.md b/suixinkan/Core/Networking/Networking.md new file mode 100644 index 0000000..c35d4bc --- /dev/null +++ b/suixinkan/Core/Networking/Networking.md @@ -0,0 +1,305 @@ +# Networking 模块业务逻辑 + +## 模块职责 + +Networking 模块是 App 的统一网络请求入口。业务模块不直接使用 `URLSession`,而是通过自己的 API 类创建 `APIRequest`,再交给 `APIClient.send` 发送。 + +这个模块负责: +- 维护不同环境的服务器地址。 +- 描述一次接口请求的方法、路径、query、header、body 和响应类型。 +- 把业务请求转换成 `URLRequest`。 +- 自动注入公共 Header 和登录 token。 +- 发送请求并处理网络错误。 +- 校验 HTTP 状态码。 +- 解包后端统一 Envelope。 +- 把错误转换成统一的 `APIError`。 + +## 文件职责 + +- `APIEnvironment.swift`:定义生产环境、测试环境、HTTP baseURL 和 WebSocket 地址。 +- `APIRequest.swift`:定义强类型请求模型,业务 API 通过它声明接口。 +- `APIClient.swift`:统一网络客户端,负责真正构造、发送、解析请求。 +- `APIEnvelope.swift`:定义后端统一响应结构。 +- `APIError.swift`:定义网络层错误和登录失效判断。 + +## 请求调用链路 + +一次业务请求的大致流程是: + +1. 业务 API 创建 `APIRequest`。 +2. `APIClient.send` 接收这个请求。 +3. `APIClient.makeURLRequest` 把 `APIRequest` 转成 `URLRequest`。 +4. `APIClient` 注入公共 Header、业务 Header、token 和请求体。 +5. `URLSessionProtocol.data(for:)` 发送请求。 +6. `APIClient.validateHTTPResponse` 校验 HTTP 状态码。 +7. `APIClient.decodeEnvelope` 解包后端 Envelope。 +8. 成功时返回 `Response` 类型的业务数据。 +9. 失败时抛出 `APIError`。 + +## APIRequest 的作用 + +`APIRequest` 是业务接口和网络底层之间的桥梁。 + +它包含: +- `method`:HTTP 方法,目前支持 GET、POST、PUT、DELETE。 +- `path`:接口路径,例如 `/api/app/v9/login`。 +- `queryItems`:URL query 参数。 +- `headers`:接口额外 Header。 +- `body`:请求体,内部通过 `AnyEncodable` 做类型擦除。 +- `Response`:接口成功后期望返回的数据类型。 + +示例: + +```swift +try await client.send( + APIRequest( + method: .post, + path: "/api/app/v9/login", + body: LoginRequest(username: username, password: password) + ) +) +``` + +业务 API 类应该负责创建 `APIRequest`,View 和 ViewModel 不应该直接拼 URL。 + +## URLRequest 构造规则 + +`APIClient.makeURLRequest` 会做这些事情: + +1. 如果 `path` 没有 `/` 前缀,会自动补上。 +2. 使用 `APIEnvironment.baseURL + path` 生成完整 URL。 +3. 如果 `queryItems` 非空,则写入 URL query。 +4. 设置 HTTP method。 +5. 设置公共 Header: + - `Content-Type: application/json` + - `Accept: application/json` + - `X-APP-VERSION` + - `X-OS-TYPE` +6. 合并业务 API 传入的额外 Header。 +7. 选择并注入 token。 +8. 如果有 body,则使用 `JSONEncoder` 编码为 JSON。 + +## token 注入规则 + +token 有两个来源: + +1. `tokenOverride` +2. `authTokenProvider` + +优先级是: + +```text +tokenOverride > authTokenProvider() +``` + +正常登录后的接口走 `authTokenProvider`。`RootView` 启动时会绑定: + +```swift +apiClient.bindAuthTokenProvider { appSession.token } +``` + +登录流程里的 `set-user` 比较特殊,它需要使用登录接口返回的临时 token,所以会传入 `tokenOverride`。 + +token 最终会写入请求 Header: + +```text +token: +``` + +空 token 不会写入 Header。 + +## 环境选择 + +`APIEnvironment.current` 根据编译环境选择接口地址: + +- Debug:`https://api-test.zhifly.cn` +- Release:`https://api.zhifly.cn` + +WebSocket 地址也在 `APIEnvironment` 中定义,当前网络客户端主要使用 HTTP baseURL。 + +## App 版本 Header + +`AppClientInfo.appVersion` 会从 `Info.plist` 读取: + +- `CFBundleShortVersionString` +- `CFBundleVersion` + +如果版本号已经有三段,则直接使用版本号。 +如果版本号只有两段,并且有 build 号,则拼成 `版本号.build`。 +如果读取失败,则兜底为 `1.0.0`。 + +这个值会作为 `X-APP-VERSION` Header 发送给后端。 + +系统类型固定为: + +```text +X-OS-TYPE: iOS +``` + +## 后端 Envelope 约定 + +后端统一响应结构由 `APIEnvelope` 表示: + +```swift +struct APIEnvelope: Decodable { + let data: T? + let code: Int + let msg: String? +} +``` + +当前成功业务码是: + +```text +100000 +``` + +只有 `code == 100000` 时才会继续解析 `data`。 + +如果接口成功但没有业务数据,使用 `EmptyPayload`: + +```swift +let _: EmptyPayload = try await client.send(...) +``` + +## 错误处理规则 + +网络错误分为几层: + +### 1. URL 构造错误 + +URL 拼接失败时抛出: + +```swift +APIError.invalidURL +``` + +### 2. URLSession 错误 + +`URLError` 会被转换成中文提示: + +- 超时:请求超时,请稍后重试 +- 无网络:网络不可用,请检查网络连接 +- 连接中断:网络连接中断,请重试 +- 无法连接服务器:无法连接服务器,请稍后重试 + +取消请求会继续抛出 `CancellationError`,不会包装成业务错误。 + +### 3. HTTP 状态码错误 + +非 2xx 状态码会抛出: + +```swift +APIError.httpStatus(statusCode, message) +``` + +错误文案优先从响应体里解析: + +1. `msg` +2. `message` +3. `error` +4. plain text 响应体 +5. 兜底文案“服务端返回错误” + +### 4. Envelope 解码错误 + +响应体无法按 `APIEnvelope` 解码时抛出: + +```swift +APIError.decodeFailed(message) +``` + +### 5. 后端业务码错误 + +HTTP 成功但 `code != 100000` 时抛出: + +```swift +APIError.serverCode(code, msg) +``` + +### 6. 空数据错误 + +接口声明需要返回 `Response`,但 Envelope 里没有 `data` 时抛出: + +```swift +APIError.emptyData +``` + +## 登录失效判断 + +`APIError.isAuthenticationExpired` 用于判断是否需要清空登录态。 + +会被视为登录失效的情况: +- HTTP 401 +- HTTP 403 +- 业务码 `200001` +- 错误文案包含: + - `token` + - `过期` + - `登录失效` + - `重新登录` + - `unauthorized` + - `验证失败` + - `驗證失敗` + +`SessionBootstrapper` 会用这个方法判断冷启动校验失败时是否要清空 token 和账号快照。 + +账号上下文相关接口集中在 `AccountContextAPI`: + +- `rolePermissions()` 读取角色权限。 +- `scenicListAll()` 读取景区列表。 +- `storeAll()` 读取门店列表。 +- `scenicSpotListAll(scenicId:)` 按景区读取景点/打卡点列表。 + +这些接口仍然只通过 `APIClient.send` 发起请求,token 由 `APIClient` 的 token provider 注入。 + +## Debug 日志 + +Debug 环境下,`APIClient` 会打印: + +- 请求方法和 URL +- 响应状态码 +- 格式化后的响应体 +- 被取消的请求信息 + +Release 环境不会打印这些日志。 + +## 新增接口的推荐写法 + +新增业务接口时,优先按这个结构写: + +```swift +@MainActor +@Observable +final class SomeFeatureAPI { + @ObservationIgnored private let client: APIClient + + init(client: APIClient) { + self.client = client + } + + func loadData() async throws -> SomeResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/example/path" + ) + ) + } +} +``` + +规则: +- API 类只负责接口封装,不处理 UI 状态。 +- ViewModel 调用 API 类,不直接调用 `APIClient`。 +- 请求体单独定义 `Encodable` Model。 +- 响应体单独定义 `Decodable` Model。 +- 接口成功无 data 时使用 `EmptyPayload`。 +- 需要临时 token 的接口使用 `tokenOverride`。 + +## 当前注意点 + +- `APIClient` 是 `@MainActor @Observable`,当前用于方便通过 Environment 注入和共享。网络发送本身是 async,不会阻塞主线程等待网络返回。 +- `URLSessionProtocol` 用于后续单元测试注入假 session。 +- `APIEnvelope.isSuccess` 当前只认 `100000`,如果后端未来新增成功码,需要集中改这里。 +- `APIEnvironment.current` 在 Debug 下默认测试环境,真机调试时需要注意接口环境。 diff --git a/suixinkan/Core/Storage/AccountSnapshotStore.swift b/suixinkan/Core/Storage/AccountSnapshotStore.swift new file mode 100644 index 0000000..eb1f1fa --- /dev/null +++ b/suixinkan/Core/Storage/AccountSnapshotStore.swift @@ -0,0 +1,86 @@ +// +// AccountSnapshotStore.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation +import Observation + +/// 账号缓存快照实体,保存可重建、非敏感的账号展示和业务上下文。 +struct AccountSnapshot: Codable, Equatable { + var profile: AccountProfile? + var accountType: String? + var businessUserId: Int? + var currentRoleId: Int? + var scenicScopes: [BusinessScope] + var storeScopes: [BusinessScope] + var currentScenicId: Int? + var currentStoreId: Int? + + /// 创建账号缓存快照,默认没有业务作用域。 + init( + profile: AccountProfile? = nil, + accountType: String? = nil, + businessUserId: Int? = nil, + currentRoleId: Int? = nil, + scenicScopes: [BusinessScope] = [], + storeScopes: [BusinessScope] = [], + currentScenicId: Int? = nil, + currentStoreId: Int? = nil + ) { + self.profile = profile + self.accountType = accountType + self.businessUserId = businessUserId + self.currentRoleId = currentRoleId + self.scenicScopes = scenicScopes + self.storeScopes = storeScopes + self.currentScenicId = currentScenicId + self.currentStoreId = currentStoreId + } +} + +@Observable +/// 账号快照存储服务,使用 UserDefaults 保存非敏感登录上下文。 +final class AccountSnapshotStore { + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let key: String + @ObservationIgnored private let encoder: JSONEncoder + @ObservationIgnored private let decoder: JSONDecoder + + /// 初始化账号快照存储服务,并允许测试注入独立的 UserDefaults。 + init( + defaults: UserDefaults = .standard, + key: String = "suixinkan.account.snapshot.v1", + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder() + ) { + self.defaults = defaults + self.key = key + self.encoder = encoder + self.decoder = decoder + } + + /// 保存账号快照,编码失败时保持原缓存不变。 + func save(_ snapshot: AccountSnapshot) { + guard let data = try? encoder.encode(snapshot) else { return } + defaults.set(data, forKey: key) + } + + /// 读取账号快照,解码失败时清空损坏数据。 + func load() -> AccountSnapshot? { + guard let data = defaults.data(forKey: key) else { return nil } + do { + return try decoder.decode(AccountSnapshot.self, from: data) + } catch { + clear() + return nil + } + } + + /// 清空账号快照缓存。 + func clear() { + defaults.removeObject(forKey: key) + } +} diff --git a/suixinkan/Core/Storage/AppPreferencesStore.swift b/suixinkan/Core/Storage/AppPreferencesStore.swift new file mode 100644 index 0000000..d89cd5c --- /dev/null +++ b/suixinkan/Core/Storage/AppPreferencesStore.swift @@ -0,0 +1,46 @@ +// +// AppPreferencesStore.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation +import Observation + +@Observable +/// App 偏好存储服务,保存上次手机号和协议状态等非敏感设置。 +final class AppPreferencesStore { + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username" + @ObservationIgnored private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted" + + /// 初始化偏好存储服务,并允许测试注入独立的 UserDefaults。 + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// 保存上次成功登录的手机号。 + func saveLastLoginUsername(_ username: String) { + let value = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + defaults.set(value, forKey: lastLoginUsernameKey) + } + + /// 读取上次成功登录的手机号。 + func loadLastLoginUsername() -> String? { + let value = defaults.string(forKey: lastLoginUsernameKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? nil : value + } + + /// 保存用户是否已经同意登录页协议。 + func savePrivacyAgreementAccepted(_ accepted: Bool) { + defaults.set(accepted, forKey: privacyAgreementAcceptedKey) + } + + /// 读取用户是否已经同意登录页协议。 + func loadPrivacyAgreementAccepted() -> Bool { + defaults.bool(forKey: privacyAgreementAcceptedKey) + } +} diff --git a/suixinkan/Core/Storage/SessionTokenStore.swift b/suixinkan/Core/Storage/SessionTokenStore.swift new file mode 100644 index 0000000..fcc505a --- /dev/null +++ b/suixinkan/Core/Storage/SessionTokenStore.swift @@ -0,0 +1,102 @@ +// +// SessionTokenStore.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation +import Observation +import Security + +/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。 +enum SessionTokenStoreError: LocalizedError { + case unexpectedStatus(OSStatus) + + var errorDescription: String? { + switch self { + case let .unexpectedStatus(status): + "登录凭证存储失败(\(status))" + } + } +} + +@Observable +/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。 +final class SessionTokenStore { + @ObservationIgnored private let service: String + @ObservationIgnored private let account: String + + /// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。 + init( + service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan", + account: String = "session.token" + ) { + self.service = service + self.account = account + } + + /// 保存正式 token,空 token 会被视为清空凭证。 + func save(_ token: String) throws { + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedToken.isEmpty else { + try clear() + return + } + + let data = Data(trimmedToken.utf8) + let query = baseQuery() + let updateAttributes: [String: Any] = [kSecValueData as String: data] + + let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary) + if updateStatus == errSecSuccess { + return + } + guard updateStatus == errSecItemNotFound else { + throw SessionTokenStoreError.unexpectedStatus(updateStatus) + } + + var addQuery = query + updateAttributes.forEach { addQuery[$0.key] = $0.value } + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw SessionTokenStoreError.unexpectedStatus(addStatus) + } + } + + /// 读取本地正式 token,读取失败或无值时返回 nil。 + func load() -> String? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let token = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty else { + return nil + } + return token + } + + /// 清空本地正式 token。 + func clear() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw SessionTokenStoreError.unexpectedStatus(status) + } + } + + /// 构造当前 App 使用的 Keychain 查询条件。 + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } +} diff --git a/suixinkan/Features/Account/API/AccountContextAPI.swift b/suixinkan/Features/Account/API/AccountContextAPI.swift new file mode 100644 index 0000000..820680f --- /dev/null +++ b/suixinkan/Features/Account/API/AccountContextAPI.swift @@ -0,0 +1,78 @@ +// +// AccountContextAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +/// 账号上下文服务协议,抽象权限、景区、门店和景点读取能力以便测试替换。 +protocol AccountContextServing { + /// 获取当前账号的角色权限列表。 + func rolePermissions() async throws -> [RolePermissionResponse] + + /// 获取当前账号可访问的景区列表。 + func scenicListAll() async throws -> ScenicListAllResponse + + /// 获取当前账号可访问的门店列表。 + func storeAll() async throws -> ListPayload + + /// 获取指定景区下的景点或打卡点列表。 + func scenicSpotListAll(scenicId: Int) async throws -> ListPayload +} + +@MainActor +@Observable +/// 账号上下文 API,封装角色权限、景区、门店和景点读取接口。 +final class AccountContextAPI: AccountContextServing { + @ObservationIgnored private let client: APIClient + + /// 初始化账号上下文 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取当前账号的角色权限列表。 + func rolePermissions() async throws -> [RolePermissionResponse] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/role-permission" + ) + ) + } + + /// 获取当前账号可访问的景区列表。 + func scenicListAll() async throws -> ScenicListAllResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic/list-all" + ) + ) + } + + /// 获取当前账号可访问的门店列表。 + func storeAll() async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/all" + ) + ) + } + + /// 获取指定景区下的景点或打卡点列表。 + func scenicSpotListAll(scenicId: Int) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic-spot/list-all", + queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")] + ) + ) + } +} diff --git a/suixinkan/Features/Account/Account.md b/suixinkan/Features/Account/Account.md new file mode 100644 index 0000000..8ae4a93 --- /dev/null +++ b/suixinkan/Features/Account/Account.md @@ -0,0 +1,36 @@ +# Account 模块业务逻辑 + +## 模块职责 + +Account 模块负责同步账号进入业务页前必须具备的上下文数据,包括角色权限、景区、门店和当前景区下的景点/打卡点。 + +该模块只做读取和上下文装配,不负责登录 token 存储,也不负责页面导航。 + +## 数据边界 + +- 角色权限:由 `/api/yf-handset-app/role-permission` 返回,写入 `PermissionContext`。 +- 景区列表:由 `/api/yf-handset-app/photog/scenic/list-all` 返回,用于账号业务作用域。 +- 门店列表:由 `/api/app/store/all` 返回,并通过 `scenic_id` 关联景区。 +- 景点/打卡点:由 `/api/yf-handset-app/photog/scenic-spot/list-all` 按当前景区懒加载。 + +## 加载流程 + +登录成功或冷启动恢复时,`AccountContextLoader` 会并行请求用户资料和角色权限。权限成功后继续读取景区和门店: + +1. 用户资料刷新 `AccountContext.profile`。 +2. 角色权限刷新 `PermissionContext`,并计算当前角色的权限 URI 集合。 +3. 景区列表失败时,从角色权限里的景区数据去重兜底。 +4. 门店列表失败时不阻断登录,只写入空门店列表。 +5. 当前景区确定后,`ScenicSpotContext` 再按景区 ID 拉取景点/打卡点。 + +## 切换规则 + +- 切换角色时,当前景区优先保留在新角色可访问景区中,否则选择新角色第一个景区。 +- 切换景区时,当前门店优先保留同景区门店,否则选择该景区第一个门店。 +- 切换景区后,景点/打卡点列表会重新加载。 + +## 缓存规则 + +`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前角色 ID、当前景区 ID、当前门店 ID 和基础账号展示信息。 + +景点/打卡点列表只保存在内存中,不长期落盘。后续业务需要离线缓存时,需要按账号类型、业务账号 ID 和景区 ID 做隔离。 diff --git a/suixinkan/Features/Account/Models/AccountContextModels.swift b/suixinkan/Features/Account/Models/AccountContextModels.swift new file mode 100644 index 0000000..bae8078 --- /dev/null +++ b/suixinkan/Features/Account/Models/AccountContextModels.swift @@ -0,0 +1,355 @@ +// +// AccountContextModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 角色权限响应实体,表示一个角色、该角色权限树以及可访问景区。 +struct RolePermissionResponse: Decodable, Equatable { + let role: RoleInfo + let scenic: [ScenicInfo] +} + +/// 角色实体,表示用户可切换的业务角色及其权限树。 +struct RoleInfo: Decodable, Equatable, Identifiable { + let id: Int + let name: String + let notes: String? + let permission: [PermissionItem] + + /// 角色响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + case notes + case permission + } + + /// 创建角色实体,主要用于测试和本地状态恢复。 + init(id: Int, name: String, notes: String? = nil, permission: [PermissionItem] = []) { + self.id = id + self.name = name + self.notes = notes + self.permission = permission + } + + /// 自定义解码,兼容 ID 类型不稳定和权限树缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + notes = try container.decodeIfPresent(String.self, forKey: .notes) + permission = try container.decodeIfPresent([PermissionItem].self, forKey: .permission) ?? [] + } +} + +/// 权限节点实体,表示一个菜单或功能权限,并可递归包含子权限。 +struct PermissionItem: Decodable, Equatable, Identifiable { + let id: Int + let pid: Int + let name: String + let uri: String + let iconSrc: String? + let children: [PermissionItem] + + /// 权限节点响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case pid + case name + case uri + case iconSrc = "icon_src" + case children + } + + /// 创建权限节点,主要用于测试和本地构造权限树。 + init(id: Int, pid: Int = 0, name: String, uri: String = "", iconSrc: String? = nil, children: [PermissionItem] = []) { + self.id = id + self.pid = pid + self.name = name + self.uri = uri + self.iconSrc = iconSrc + self.children = children + } + + /// 自定义解码,兼容 ID 类型不稳定和 children 缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + pid = try container.decodeLossyInt(forKey: .pid) ?? 0 + name = try container.decodeLossyString(forKey: .name) + uri = try container.decodeLossyString(forKey: .uri) + iconSrc = try container.decodeIfPresent(String.self, forKey: .iconSrc) + children = try container.decodeIfPresent([PermissionItem].self, forKey: .children) ?? [] + } +} + +/// 景区权限实体,表示某个角色可访问的景区。 +struct ScenicInfo: Decodable, Equatable, Identifiable { + let id: Int + let name: String + let status: Int? + let location: ScenicLocationInfo? + let coverImg: String? + + /// 景区权限响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + case status + case location + case coverImg = "cover_img" + } + + /// 创建景区权限实体,主要用于测试和兜底列表生成。 + init(id: Int, name: String, status: Int? = nil, location: ScenicLocationInfo? = nil, coverImg: String? = nil) { + self.id = id + self.name = name + self.status = status + self.location = location + self.coverImg = coverImg + } + + /// 自定义解码,兼容 location 是对象或 JSON 字符串。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + status = try container.decodeLossyInt(forKey: .status) + coverImg = try container.decodeIfPresent(String.self, forKey: .coverImg) + if let value = try? container.decodeIfPresent(ScenicLocationInfo.self, forKey: .location) { + location = value + } else if let rawValue = try? container.decodeIfPresent(String.self, forKey: .location), + let data = rawValue.data(using: .utf8) { + location = try? JSONDecoder().decode(ScenicLocationInfo.self, from: data) + } else { + location = nil + } + } +} + +/// 景区位置实体,表示景区经纬度和地址。 +struct ScenicLocationInfo: Decodable, Equatable { + let lng: Double + let lat: Double + let address: String + + /// 景区位置响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case lng + case lat + case address + } + + /// 创建景区位置实体,主要用于测试和本地构造。 + init(lng: Double, lat: Double, address: String) { + self.lng = lng + self.lat = lat + self.address = address + } + + /// 自定义解码,兼容经纬度是字符串或数字。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + lng = try container.decodeLossyDouble(forKey: .lng) ?? 0 + lat = try container.decodeLossyDouble(forKey: .lat) ?? 0 + address = try container.decodeLossyString(forKey: .address) + } +} + +/// 景区列表响应实体,表示用户可选择的景区列表。 +struct ScenicListAllResponse: Decodable, Equatable { + let total: Int + let list: [ScenicListItem] + + /// 景区列表响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case total + case list + } + + /// 创建景区列表响应,主要用于测试和兜底数据。 + init(total: Int, list: [ScenicListItem]) { + self.total = total + self.list = list + } + + /// 自定义解码,兼容 total 类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + list = try container.decodeIfPresent([ScenicListItem].self, forKey: .list) ?? [] + } +} + +/// 景区列表项实体,表示一个可进入的景区。 +struct ScenicListItem: Decodable, Equatable, Identifiable { + let id: Int + let name: String + + /// 景区列表项响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + } + + /// 创建景区列表项,主要用于测试和本地构造。 + init(id: Int, name: String) { + self.id = id + self.name = name + } + + /// 自定义解码,兼容 ID 类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + } +} + +/// 门店实体,表示一个景区下可进入的门店。 +struct StoreItem: Decodable, Equatable, Identifiable { + let id: Int + let scenicId: Int + let name: String + let address: String + let status: Int + let statusText: String + let tel: String? + let logo: String? + + /// 门店响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case scenicId = "scenic_id" + case name + case address + case status + case statusText = "status_text" + case tel + case logo + } + + /// 创建门店实体,主要用于测试和本地构造。 + init( + id: Int, + scenicId: Int, + name: String, + address: String = "", + status: Int = 0, + statusText: String = "", + tel: String? = nil, + logo: String? = nil + ) { + self.id = id + self.scenicId = scenicId + self.name = name + self.address = address + self.status = status + self.statusText = statusText + self.tel = tel + self.logo = logo + } + + /// 自定义解码,兼容字段类型不稳定和可选字段缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 + name = try container.decodeLossyString(forKey: .name) + address = try container.decodeLossyString(forKey: .address) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusText = try container.decodeLossyString(forKey: .statusText) + tel = try container.decodeIfPresent(String.self, forKey: .tel) + logo = try container.decodeIfPresent(String.self, forKey: .logo) + } +} + +/// 景点实体,表示某个景区下的景点或打卡点。 +struct ScenicSpotItem: Decodable, Equatable, Identifiable { + let id: Int + let name: String + let status: Int + let statusLabel: String + + /// 景点响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + case status + case statusLabel = "status_label" + } + + /// 创建景点实体,主要用于测试和本地构造。 + init(id: Int, name: String, status: Int = 0, statusLabel: String = "") { + self.id = id + self.name = name + self.status = status + self.statusLabel = statusLabel + } + + /// 自定义解码,兼容字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusLabel = try container.decodeLossyString(forKey: .statusLabel) + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } + + /// 将 Double、Int 或数字字符串宽松解码为浮点数。 + func decodeLossyDouble(forKey key: Key) throws -> Double? { + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} diff --git a/suixinkan/Features/Auth/API/AuthAPI.swift b/suixinkan/Features/Auth/API/AuthAPI.swift new file mode 100644 index 0000000..846ab1c --- /dev/null +++ b/suixinkan/Features/Auth/API/AuthAPI.swift @@ -0,0 +1,44 @@ +// +// AuthAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 登录认证 API,封装 v9 登录和账号选择相关接口。 +final class AuthAPI { + @ObservationIgnored private let client: APIClient + + /// 初始化登录 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 使用手机号和密码发起 v9 登录,返回临时 token 及可选账号列表。 + func login(username: String, password: String) async throws -> V9AuthResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/app/v9/login", + body: LoginRequest(username: username, password: password) + ) + ) + } + + /// 选择具体景区或门店账号,换取正式登录 token。 + func setUser(_ request: SetUserRequest, tokenOverride: String? = nil) async throws -> V9AuthResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/app/v9/set-user", + body: request + ), + tokenOverride: tokenOverride + ) + } +} diff --git a/suixinkan/Features/Auth/Auth.md b/suixinkan/Features/Auth/Auth.md new file mode 100644 index 0000000..716d13a --- /dev/null +++ b/suixinkan/Features/Auth/Auth.md @@ -0,0 +1,63 @@ +# Auth 模块业务逻辑 + +## 模块职责 + +Auth 模块负责登录页、手机号密码登录、多账号选择和账号选择后的正式登录确认。 + +该模块只处理登录流程本身: +- 表单输入、校验和协议确认。 +- 调用 v9 登录接口获取临时 token 和账号列表。 +- 单账号时自动调用 set-user。 +- 多账号时展示账号选择页。 +- 选择账号后调用 set-user 换取正式 token。 + +正式 token 存储、账号快照和全局登录态写入由 App 模块的 `AuthSessionCoordinator` 统一处理。 + +## 核心对象 + +- `LoginView`:登录页 UI,展示手机号、密码、协议确认和登录按钮。 +- `AccountSelectionView`:多账号选择弹窗,展示景区账号和门店账号。 +- `LoginViewModel`:维护表单状态、校验状态、登录请求状态和多账号选择状态。 +- `AuthAPI`:封装 `/api/app/v9/login` 和 `/api/app/v9/set-user`。 +- `LoginRequest`:登录请求体。 +- `SetUserRequest`:账号选择请求体。 +- `V9AuthResponse`:v9 登录和 set-user 响应。 +- `V9ScenicUser` / `V9StoreUser`:后端返回的景区账号和门店账号。 +- `AccountSwitchAccount`:统一后的可选择账号模型。 + +## 登录流程 + +1. 用户输入手机号和密码。 +2. `LoginViewModel.validateForLogin` 校验手机号、密码和协议勾选状态。 +3. 未勾选协议时,`LoginView` 弹出协议确认 Sheet。 +4. 校验通过后,`LoginViewModel.login` 调用 `AuthAPI.login`。 +5. 登录接口返回临时 token 和可用账号列表。 +6. `LoginViewModel` 过滤掉业务账号 ID 无效的账号。 +7. 没有可用账号时抛出 `LoginFlowError.noAvailableAccount`。 +8. 只有一个账号时,自动调用 `AuthAPI.setUser`,并返回 `.completed`。 +9. 多个账号时,保存 `AccountSelectionPayload`,并返回 `.needsAccountSelection`。 +10. `LoginView` 展示 `AccountSelectionView`。 +11. 用户确认账号后,`LoginViewModel.selectAccount` 使用临时 token 调用 set-user。 +12. set-user 返回正式 token 后,`LoginView` 调用 `AuthSessionCoordinator.completeLogin` 写入 token,并同步用户资料、角色权限、景区和门店。 + +## 账号模型转换 + +后端景区账号和门店账号字段不完全一致,统一转换为 `AccountSwitchAccount`: +- 景区账号通过 `ssUserId`、`scenicUserId`、`userId`、`id` 兜底生成业务账号 ID。 +- 门店账号通过 `storeUserId`、`userId`、`id` 兜底生成业务账号 ID。 +- `AccountSwitchAccount.toSetUserRequest` 根据账号类型生成 `store_user_id` 或 `ss_user_id`。 + +`V9AuthResponse` 还会派生: +- `accounts`:合并后的账号选择列表。 +- `primaryProfile`:登录后用于全局展示的账号资料。 +- `scenicScopes`:当前账号可用景区作用域。 +- `storeScopes`:当前账号可用门店作用域。 + +## 缓存规则 + +Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责: +- 正式 token 写 Keychain。 +- 上次手机号和协议状态写 UserDefaults。 +- 账号资料、当前角色和业务作用域写入账号快照。 + +临时 token 只存在于 `AccountSelectionPayload`,不落盘。 diff --git a/suixinkan/Features/Auth/Models/AuthModels.swift b/suixinkan/Features/Auth/Models/AuthModels.swift new file mode 100644 index 0000000..a02b708 --- /dev/null +++ b/suixinkan/Features/Auth/Models/AuthModels.swift @@ -0,0 +1,409 @@ +// +// AuthModels.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 登录请求实体,表示手机号密码登录接口需要的参数。 +struct LoginRequest: Encodable { + let username: String + let type: Int + let password: String + let mobile: String + let code: String + + /// 创建手机号密码登录请求,并填充后端要求的默认字段。 + init(username: String, password: String) { + self.username = username + self.type = 1 + self.password = password + self.mobile = "" + self.code = "" + } +} + +/// 账号选择请求实体,表示 set-user 接口需要的景区账号或门店账号 ID。 +struct SetUserRequest: Encodable, Equatable { + let storeUserId: Int? + let ssUserId: Int? + + /// set-user 请求的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case storeUserId = "store_user_id" + case ssUserId = "ss_user_id" + } + + /// 创建账号选择请求,门店账号传 storeUserId,景区账号传 ssUserId。 + init(storeUserId: Int? = nil, ssUserId: Int? = nil) { + self.storeUserId = storeUserId + self.ssUserId = ssUserId + } +} + +/// 可切换账号实体,统一表示登录返回的景区账号和门店账号。 +struct AccountSwitchAccount: Identifiable, Hashable { + let accountType: String + let businessUserId: Int + let title: String + let subtitle: String + let phone: String + let avatar: String + let scenicName: String + let storeId: Int? + let storeName: String + let scenicId: Int? + let isCurrent: Bool + + var id: String { + "\(accountType)_\(businessUserId)" + } + + var isStoreUser: Bool { + accountType == V9StoreUser.accountTypeValue + } + + var accountTypeLabel: String { + isStoreUser ? "门店账号" : "景区账号" + } + + /// 转换为 set-user 接口请求参数。 + func toSetUserRequest() -> SetUserRequest { + if isStoreUser { + return SetUserRequest(storeUserId: businessUserId) + } + return SetUserRequest(ssUserId: businessUserId) + } +} + +/// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。 +struct AccountSelectionPayload: Equatable, Identifiable { + let id = UUID() + let tempToken: String + let accounts: [AccountSwitchAccount] + + var hasTempToken: Bool { + !tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +/// v9 登录响应实体,包含 token、景区账号列表和门店账号列表。 +struct V9AuthResponse: Decodable, Equatable { + let token: String + let scenicUsers: [V9ScenicUser] + let storeUsers: [V9StoreUser] + + /// 合并景区账号和门店账号,供账号选择页展示。 + var accounts: [AccountSwitchAccount] { + scenicUsers.map { $0.toAccountSwitchAccount() } + storeUsers.map { $0.toAccountSwitchAccount() } + } + + /// 提取当前或首个账号作为全局账号资料。 + var primaryProfile: AccountProfile? { + if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first { + return AccountProfile( + userId: String(storeUser.userId), + displayName: storeUser.displayName, + phone: storeUser.phone.nonEmpty, + avatarURL: storeUser.avatar.nonEmpty + ) + } + + if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first { + return AccountProfile( + userId: String(scenicUser.userId), + displayName: scenicUser.displayName, + phone: scenicUser.phone.nonEmpty, + avatarURL: nil + ) + } + + return nil + } + + /// 提取登录响应中的景区作用域,并按当前账号优先排序。 + var scenicScopes: [BusinessScope] { + var seen = Set() + let scenicFromScenicUsers = scenicUsers + .sorted { $0.isCurrent && !$1.isCurrent } + .compactMap { user -> BusinessScope? in + guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil } + return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic) + } + let scenicFromStoreUsers = storeUsers + .sorted { $0.isCurrent && !$1.isCurrent } + .compactMap { user -> BusinessScope? in + guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil } + return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic) + } + return scenicFromScenicUsers + scenicFromStoreUsers + } + + /// 提取登录响应中的门店作用域,并按当前账号优先排序。 + var storeScopes: [BusinessScope] { + var seen = Set() + return storeUsers + .sorted { $0.isCurrent && !$1.isCurrent } + .compactMap { user -> BusinessScope? in + guard user.storeId > 0, seen.insert(user.storeId).inserted else { return nil } + return BusinessScope( + id: user.storeId, + name: user.storeName, + kind: .store, + parentScenicId: user.scenicId > 0 ? user.scenicId : nil + ) + } + } + + /// v9 登录响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case token + case scenicUsers = "scenic_users" + case storeUsers = "store_users" + } + + /// 创建空响应或测试响应。 + init(token: String = "", scenicUsers: [V9ScenicUser] = [], storeUsers: [V9StoreUser] = []) { + self.token = token + self.scenicUsers = scenicUsers + self.storeUsers = storeUsers + } + + /// 自定义解码,兼容后端账号数组缺失的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + token = try container.decodeLossyString(forKey: .token) + scenicUsers = (try? container.decodeIfPresent([V9ScenicUser].self, forKey: .scenicUsers)) ?? [] + storeUsers = (try? container.decodeIfPresent([V9StoreUser].self, forKey: .storeUsers)) ?? [] + } +} + +/// v9 景区账号实体,表示用户可进入的某个景区身份。 +struct V9ScenicUser: Decodable, Equatable { + static let accountTypeValue = "scenic_user" + + let accountType: String + let id: Int + let userId: Int + let scenicUserId: Int + let ssUserId: Int + let username: String + let realName: String + let nickname: String + let phone: String + let scenicId: Int + let scenicName: String + let isCurrent: Bool + + /// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。 + var businessUserId: Int { + [ssUserId, scenicUserId, userId, id].first { $0 > 0 } ?? 0 + } + + /// 用于界面展示的景区账号名称。 + var displayName: String { + scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username + } + + /// 转换为账号选择页使用的统一账号实体。 + func toAccountSwitchAccount() -> AccountSwitchAccount { + AccountSwitchAccount( + accountType: accountType.nonEmpty ?? Self.accountTypeValue, + businessUserId: businessUserId, + title: scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? "景区账号", + subtitle: [realName.nonEmpty, nickname.nonEmpty] + .compactMap(\.self) + .joined(separator: " · "), + phone: phone, + avatar: "", + scenicName: scenicName, + storeId: nil, + storeName: "", + scenicId: scenicId > 0 ? scenicId : nil, + isCurrent: isCurrent + ) + } + + /// 景区账号响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case accountType = "account_type" + case id + case userId = "user_id" + case scenicUserId = "scenic_user_id" + case ssUserId = "ss_user_id" + case username + case realName = "real_name" + case nickname + case phone + case scenicId = "scenic_id" + case scenicName = "scenic_name" + case isCurrent = "is_current" + } + + /// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue + id = try container.decodeLossyInt(forKey: .id) ?? 0 + userId = try container.decodeLossyInt(forKey: .userId) ?? 0 + scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0 + ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0 + username = try container.decodeLossyString(forKey: .username) + realName = try container.decodeLossyString(forKey: .realName) + nickname = try container.decodeLossyString(forKey: .nickname) + phone = try container.decodeLossyString(forKey: .phone) + scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 + scenicName = try container.decodeLossyString(forKey: .scenicName) + isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false + } +} + +/// v9 门店账号实体,表示用户可进入的某个门店身份。 +struct V9StoreUser: Decodable, Equatable { + static let accountTypeValue = "store_user" + + let accountType: String + let id: Int + let userId: Int + let storeUserId: Int + let username: String + let userName: String + let realName: String + let phone: String + let avatar: String + let scenicId: Int + let scenicName: String + let storeId: Int + let storeName: String + let isCurrent: Bool + + /// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。 + var businessUserId: Int { + [storeUserId, userId, id].first { $0 > 0 } ?? 0 + } + + /// 用于界面展示的门店账号名称。 + var displayName: String { + storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username + } + + /// 转换为账号选择页使用的统一账号实体。 + func toAccountSwitchAccount() -> AccountSwitchAccount { + AccountSwitchAccount( + accountType: accountType.nonEmpty ?? Self.accountTypeValue, + businessUserId: businessUserId, + title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号", + subtitle: [scenicName.nonEmpty, realName.nonEmpty ?? userName.nonEmpty] + .compactMap(\.self) + .joined(separator: " · "), + phone: phone, + avatar: avatar, + scenicName: scenicName, + storeId: storeId > 0 ? storeId : nil, + storeName: storeName, + scenicId: scenicId > 0 ? scenicId : nil, + isCurrent: isCurrent + ) + } + + /// 门店账号响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case accountType = "account_type" + case id + case userId = "user_id" + case storeUserId = "store_user_id" + case username + case userName = "user_name" + case realName = "real_name" + case phone + case avatar + case scenicId = "scenic_id" + case scenicName = "scenic_name" + case storeId = "store_id" + case storeName = "store_name" + case isCurrent = "is_current" + } + + /// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue + id = try container.decodeLossyInt(forKey: .id) ?? 0 + userId = try container.decodeLossyInt(forKey: .userId) ?? 0 + storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0 + username = try container.decodeLossyString(forKey: .username) + userName = try container.decodeLossyString(forKey: .userName) + realName = try container.decodeLossyString(forKey: .realName) + phone = try container.decodeLossyString(forKey: .phone) + avatar = try container.decodeLossyString(forKey: .avatar) + scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 + scenicName = try container.decodeLossyString(forKey: .scenicName) + storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0 + storeName = try container.decodeLossyString(forKey: .storeName) + isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } + + /// 将 Bool、数字或布尔字符串宽松解码为布尔值。 + func decodeLossyBool(forKey key: Key) throws -> Bool? { + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value != 0 + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["1", "true", "yes"].contains(normalized) { return true } + if ["0", "false", "no"].contains(normalized) { return false } + } + return nil + } +} + +private extension String { + var nonEmpty: String? { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift new file mode 100644 index 0000000..74d591f --- /dev/null +++ b/suixinkan/Features/Auth/ViewModels/LoginViewModel.swift @@ -0,0 +1,213 @@ +// +// LoginViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation +import Observation + +/// 登录输入焦点实体,标识当前需要聚焦的输入框。 +enum LoginField: Hashable { + case username + case password +} + +/// 登录表单校验错误实体,负责提供用户提示和焦点位置。 +enum LoginValidationError: Equatable { + case invalidPhone + case emptyPassword + case privacyUnchecked + + var message: String { + switch self { + case .invalidPhone: + "请输入有效的手机号码" + case .emptyPassword: + "请输入密码" + case .privacyUnchecked: + "请先阅读并同意相关协议" + } + } + + var focusField: LoginField? { + switch self { + case .invalidPhone: + .username + case .emptyPassword: + .password + case .privacyUnchecked: + nil + } + } +} + +/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。 +enum LoginResolution { + case completed(V9AuthResponse) + case needsAccountSelection(AccountSelectionPayload) +} + +/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。 +enum LoginFlowError: LocalizedError { + case missingToken + case noAvailableAccount + case invalidAccount + + var errorDescription: String? { + switch self { + case .missingToken: + "登录响应缺少 token" + case .noAvailableAccount: + "当前账号没有可用的景区账号或门店账号,请联系管理员" + case .invalidAccount: + "账号信息异常,请重新选择账号" + } + } +} + +@MainActor +@Observable +/// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。 +final class LoginViewModel { + var username = "18651857230" + var password = "zhifly666" + var privacyChecked = false + var isLoading = false + var isSelectingAccount = false + var showsPassword = false + var showsAgreementSheet = false + var pendingAccountSelection: AccountSelectionPayload? + + var canSubmit: Bool { + isValidPhone && !trimmedPassword.isEmpty + } + + var trimmedPassword: String { + password.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var normalizedUsername: String { + normalizedPhoneNumber(username) + } + + var isValidPhone: Bool { + normalizedUsername.count == 11 && normalizedUsername.first == "1" + } + + /// 应用本地登录偏好,只恢复手机号和协议状态,不恢复密码。 + func applyPreferences(_ preferences: LoginPreferences) { + if username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let lastUsername = preferences.lastUsername { + username = lastUsername + } + privacyChecked = preferences.privacyAgreementAccepted + } + + /// 校验登录表单,并返回第一个需要处理的错误。 + func validateForLogin() -> LoginValidationError? { + guard isValidPhone else { + return .invalidPhone + } + guard !trimmedPassword.isEmpty else { + return .emptyPassword + } + guard privacyChecked else { + return .privacyUnchecked + } + return nil + } + + /// 用户同意协议后关闭协议确认弹窗。 + func acceptAgreement() { + privacyChecked = true + showsAgreementSheet = false + } + + /// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。 + func normalizeUsernameCountryCodeIfNeeded() { + let digits = username.filter(\.isNumber) + let normalizedPhone = normalizedPhoneNumber(username) + guard normalizedPhone != digits, normalizedPhone.count == 11 else { return } + username = normalizedPhone + } + + /// 发起登录,并根据账号数量决定直接完成或进入账号选择。 + func login(authAPI: AuthAPI) async throws -> LoginResolution { + guard !isLoading else { + throw CancellationError() + } + + isLoading = true + defer { isLoading = false } + + let response = try await authAPI.login( + username: normalizedUsername, + password: trimmedPassword + ) + return try await resolveLoginResponse(response, authAPI: authAPI) + } + + /// 选择一个账号并调用 set-user 换取正式 token。 + func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse { + guard let payload = pendingAccountSelection, payload.hasTempToken else { + throw LoginFlowError.missingToken + } + guard account.businessUserId > 0 else { + throw LoginFlowError.invalidAccount + } + guard !isSelectingAccount else { + throw CancellationError() + } + + isSelectingAccount = true + defer { isSelectingAccount = false } + + let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken) + guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw LoginFlowError.missingToken + } + pendingAccountSelection = nil + return response + } + + /// 清空待选账号信息,通常在用户取消账号选择时调用。 + func clearPendingAccountSelection() { + pendingAccountSelection = nil + } + + /// 解析登录响应,单账号自动 set-user,多账号保留选择载荷。 + private func resolveLoginResponse(_ response: V9AuthResponse, authAPI: AuthAPI) async throws -> LoginResolution { + let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { + throw LoginFlowError.missingToken + } + + let accounts = response.accounts.filter { $0.businessUserId > 0 } + guard !accounts.isEmpty else { + throw LoginFlowError.noAvailableAccount + } + + if accounts.count == 1, let account = accounts.first { + let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token) + guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw LoginFlowError.missingToken + } + return .completed(finalResponse) + } + + let payload = AccountSelectionPayload(tempToken: token, accounts: accounts) + pendingAccountSelection = payload + return .needsAccountSelection(payload) + } + + /// 过滤手机号中的非数字字符,并去掉 86 国家码前缀。 + private func normalizedPhoneNumber(_ value: String) -> String { + var digits = value.filter(\.isNumber) + if digits.hasPrefix("86"), digits.count == 13 { + digits.removeFirst(2) + } + return digits + } +} diff --git a/suixinkan/Features/Auth/Views/AccountSelectionView.swift b/suixinkan/Features/Auth/Views/AccountSelectionView.swift new file mode 100644 index 0000000..2c5b951 --- /dev/null +++ b/suixinkan/Features/Auth/Views/AccountSelectionView.swift @@ -0,0 +1,216 @@ +// +// AccountSelectionView.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import SwiftUI + +/// 账号选择视图,展示 v9 登录返回的多个景区/门店账号并提交用户选择。 +struct AccountSelectionView: View { + let payload: AccountSelectionPayload + let isLoading: Bool + let onCancel: () -> Void + let onConfirm: (AccountSwitchAccount) -> Void + + @State private var selectedAccountId: String? + + private var selectedAccount: AccountSwitchAccount? { + payload.accounts.first { $0.id == selectedAccountId } + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + accountList + bottomBar + } + .background(Color(hex: 0xF5F7FB).ignoresSafeArea()) + .navigationTitle("选择账号") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消", action: onCancel) + } + } + .onAppear { + selectedAccountId = selectedAccountId ?? payload.accounts.first?.id + } + } + .interactiveDismissDisabled(isLoading) + } + + @ViewBuilder + private var accountList: some View { + if payload.accounts.isEmpty { + ContentUnavailableView( + "暂无可用账号", + systemImage: "person.crop.circle.badge.exclamationmark", + description: Text("当前账号没有可用的景区账号或门店账号,请联系管理员。") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(AppMetrics.Spacing.xLarge) + } else { + ScrollView { + LazyVStack(spacing: AppMetrics.Spacing.small) { + ForEach(payload.accounts) { account in + accountCard(account) + } + } + .padding(AppMetrics.Spacing.medium) + .padding(.bottom, 90) + } + } + } + + private var bottomBar: some View { + VStack(spacing: 0) { + Divider() + Button { + guard let selectedAccount else { return } + onConfirm(selectedAccount) + } label: { + HStack(spacing: AppMetrics.Spacing.xSmall) { + if isLoading { + ProgressView() + .tint(.white) + } + Text("进入系统") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + .background(canConfirm ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + .padding(.horizontal, AppMetrics.Spacing.medium) + .padding(.vertical, AppMetrics.FontSize.subheadline) + } + .buttonStyle(.plain) + .disabled(!canConfirm) + } + .background(.white) + } + + private var canConfirm: Bool { + selectedAccount != nil && !isLoading + } + + /// 渲染单个账号卡片,并处理选中状态。 + private func accountCard(_ account: AccountSwitchAccount) -> some View { + let selected = selectedAccountId == account.id + return Button { + selectedAccountId = account.id + } label: { + HStack(spacing: AppMetrics.FontSize.footnote) { + avatarFallback(account) + .frame(width: AppMetrics.ControlSize.primaryButtonHeight, height: AppMetrics.ControlSize.primaryButtonHeight) + + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Text(account.title.isEmpty ? account.accountTypeLabel : account.title) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + if account.isCurrent { + tag("当前", foreground: AppDesign.primary, background: AppDesign.primarySoft) + } + } + + if !account.subtitle.isEmpty { + Text(account.subtitle) + .font(.system(size: AppMetrics.FontSize.footnote)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(1) + } + + if !account.phone.isEmpty { + Text(account.phone) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(Color(hex: 0x9AA1AA)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .trailing, spacing: AppMetrics.Spacing.xSmall) { + tag( + account.isStoreUser ? "门店" : "景区", + foreground: account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED), + background: account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF) + ) + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.system(size: AppMetrics.ControlSize.passwordIcon, weight: .semibold)) + .foregroundStyle(selected ? AppDesign.primary : Color(hex: 0xB6BECA)) + } + } + .padding(AppMetrics.FontSize.subheadline) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + .overlay { + RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button) + .stroke(selected ? AppDesign.primary : Color(hex: 0xE6ECF4), lineWidth: selected ? 1.4 : 1) + } + .shadow(color: Color(hex: 0xC8D7EA, alpha: selected ? 0.24 : 0.12), radius: selected ? 10 : 6, x: 0, y: 5) + } + .buttonStyle(.plain) + } + + /// 构造账号头像占位视图,按景区或门店账号展示不同标识。 + private func avatarFallback(_ account: AccountSwitchAccount) -> some View { + ZStack { + Circle() + .fill(account.isStoreUser ? Color(hex: 0xE8F8F1) : Color(hex: 0xF3ECFF)) + Text(account.isStoreUser ? "店" : "景") + .font(.system(size: AppMetrics.FontSize.callout, weight: .semibold)) + .foregroundStyle(account.isStoreUser ? Color(hex: 0x0F9F6E) : Color(hex: 0x7C3AED)) + } + } + + /// 构造账号类型和当前账号标记使用的胶囊标签。 + private func tag(_ text: String, foreground: Color, background: Color) -> some View { + Text(text) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, 7) + .frame(height: AppMetrics.Spacing.sheet) + .background(background, in: RoundedRectangle(cornerRadius: AppMetrics.Spacing.xxSmall)) + } +} + +#Preview { + AccountSelectionView( + payload: AccountSelectionPayload( + tempToken: "token", + accounts: [ + AccountSwitchAccount( + accountType: V9ScenicUser.accountTypeValue, + businessUserId: 1, + title: "西湖景区", + subtitle: "摄影师", + phone: "13800000000", + avatar: "", + scenicName: "西湖景区", + storeId: nil, + storeName: "", + scenicId: 100, + isCurrent: false + ), + AccountSwitchAccount( + accountType: V9StoreUser.accountTypeValue, + businessUserId: 2, + title: "东门门店", + subtitle: "西湖景区 · 店长", + phone: "13900000000", + avatar: "", + scenicName: "西湖景区", + storeId: 200, + storeName: "东门门店", + scenicId: 100, + isCurrent: true + ) + ] + ), + isLoading: false, + onCancel: {}, + onConfirm: { _ in } + ) +} diff --git a/suixinkan/Features/Auth/Views/LoginView.swift b/suixinkan/Features/Auth/Views/LoginView.swift new file mode 100644 index 0000000..49c9cce --- /dev/null +++ b/suixinkan/Features/Auth/Views/LoginView.swift @@ -0,0 +1,482 @@ +// +// LoginView.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import SwiftUI + +/// 登录页视图,负责展示登录表单、协议确认和多账号选择入口。 +struct LoginView: View { + @Environment(AppSession.self) private var appSession + @Environment(AccountContext.self) private var accountContext + @Environment(PermissionContext.self) private var permissionContext + @Environment(ToastCenter.self) private var toastCenter + @Environment(AuthAPI.self) private var authAPI + @Environment(ProfileAPI.self) private var profileAPI + @Environment(AccountContextAPI.self) private var accountContextAPI + @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var viewModel = LoginViewModel() + @FocusState private var focusedField: LoginField? + + private var contentMaxWidth: CGFloat { + horizontalSizeClass == .regular ? 560 : .infinity + } + + var body: some View { + NavigationStack { + GeometryReader { proxy in + ZStack(alignment: .top) { + Image("LoginBackground") + .resizable() + .scaledToFill() + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + focusedField = nil + } + + ScrollView(.vertical) { + loginContent + .frame(maxWidth: contentMaxWidth, alignment: .top) + .padding(.horizontal, horizontalPadding(for: proxy.size.width)) + .padding(.top, topPadding(for: proxy)) + .padding(.bottom, AppMetrics.Spacing.xxLarge) + .frame(maxWidth: .infinity, alignment: .top) + } + .scrollDismissesKeyboard(.interactively) + } + } + .background(Color(hex: 0x0B1220).ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .sheet(isPresented: showsAgreementSheetBinding) { + LoginAgreementConsentSheet( + onOpenAgreement: { title in + toastCenter.show("\(title)页面待接入") + }, + onAgreeAndContinue: { + viewModel.acceptAgreement() + loginAction() + } + ) + } + .sheet(item: pendingAccountSelectionBinding) { payload in + AccountSelectionView( + payload: payload, + isLoading: viewModel.isSelectingAccount, + onCancel: { + viewModel.clearPendingAccountSelection() + }, + onConfirm: { account in + selectAccount(account) + } + ) + } + } + .onChange(of: viewModel.username) { _, _ in + viewModel.normalizeUsernameCountryCodeIfNeeded() + dismissLoginToastIfNeeded() + } + .onChange(of: viewModel.password) { _, _ in + dismissLoginToastIfNeeded() + } + .task { + viewModel.applyPreferences(authSessionCoordinator.loginPreferences()) + } + } + + private var loginContent: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxLarge) { + Text("欢迎使用\n随心瞰商家版") + .font(.system(size: AppMetrics.FontSize.largeTitle, weight: .semibold)) + .foregroundStyle(.white) + .lineSpacing(AppMetrics.LineSpacing.title) + .accessibilityIdentifier("login.title") + + loginCard + } + } + + private var loginCard: some View { + VStack(spacing: 0) { + VStack(spacing: AppMetrics.Spacing.xSmall) { + textInput(title: "请输入手机号", text: usernameBinding, icon: "person") + passwordInput + } + + Spacer().frame(height: AppMetrics.ControlSize.checkboxTapArea) + agreementRow + Spacer().frame(height: AppMetrics.Spacing.mediumLarge) + + Button(action: loginAction) { + HStack { + if viewModel.isLoading { + ProgressView() + .tint(.white) + .frame(width: AppMetrics.ControlSize.progressWidth) + } else { + Spacer() + .frame(width: AppMetrics.ControlSize.progressWidth) + } + + Text("登录") + .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) + .foregroundStyle(.white) + + Spacer() + .frame(width: AppMetrics.ControlSize.progressWidth) + } + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + .background(viewModel.canSubmit ? AppDesign.primary : Color.gray) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + } + .disabled(!viewModel.canSubmit || viewModel.isLoading) + .accessibilityIdentifier("login.submit") + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + .shadow(color: .black.opacity(0.12), radius: 18, x: 0, y: 8) + } + + /// 计算登录内容相对安全区的顶部距离。 + private func topPadding(for proxy: GeometryProxy) -> CGFloat { + max(proxy.safeAreaInsets.top + 24, 72) + } + + /// 根据屏幕宽度计算登录内容的水平边距。 + private func horizontalPadding(for width: CGFloat) -> CGFloat { + min(max(width * 0.07, 20), 30) + } + + /// 处理登录按钮点击,完成表单校验并启动登录流程。 + private func loginAction() { + focusedField = nil + + if let validationError = viewModel.validateForLogin() { + if validationError == .privacyUnchecked { + viewModel.showsAgreementSheet = true + } else { + toastCenter.show(validationError.message) + focusedField = validationError.focusField + } + return + } + + Task { + do { + let resolution = try await viewModel.login(authAPI: authAPI) + switch resolution { + case let .completed(response): + await completeLogin(with: response) + case .needsAccountSelection: + break + } + } catch is CancellationError { + // Keep cancellation silent; the current task was abandoned by SwiftUI. + } catch { + toastCenter.show(error.localizedDescription) + } + } + } + + /// 处理账号选择页的确认动作,并使用所选账号换取正式 token。 + private func selectAccount(_ account: AccountSwitchAccount) { + Task { + do { + let response = try await viewModel.selectAccount(account, authAPI: authAPI) + await completeLogin(with: response) + } catch is CancellationError { + // Keep cancellation silent; the current task was abandoned by SwiftUI. + } catch { + toastCenter.show(error.localizedDescription) + } + } + } + + /// 将最终登录响应写入全局会话和账号上下文。 + private func completeLogin(with response: V9AuthResponse) async { + do { + try await authSessionCoordinator.completeLogin( + with: response, + username: viewModel.normalizedUsername, + privacyAgreementAccepted: viewModel.privacyChecked, + appSession: appSession, + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: profileAPI, + accountContextAPI: accountContextAPI + ) + } catch { + toastCenter.show(error.localizedDescription) + } + } + + /// 输入内容变化后清除当前 Toast,避免旧错误提示停留。 + private func dismissLoginToastIfNeeded() { + toastCenter.dismiss() + } + + /// 构造通用文本输入框,当前用于手机号输入。 + private func textInput(title: String, text: Binding, icon: String) -> some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Image(systemName: icon) + .font(.system(size: AppMetrics.ControlSize.smallIcon)) + .foregroundStyle(AppDesign.textSecondary) + + TextField(title, text: text) + .font(.system(size: AppMetrics.FontSize.body)) + .textFieldStyle(.plain) + .foregroundStyle(AppDesign.textPrimary) + .tint(AppDesign.primary) + .keyboardType(.phonePad) + .textContentType(.telephoneNumber) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .submitLabel(.next) + .focused($focusedField, equals: .username) + .onSubmit { + focusedField = .password + } + .accessibilityIdentifier("login.username") + } + .padding(.horizontal, AppMetrics.Spacing.medium) + .frame(height: AppMetrics.ControlSize.inputHeight) + .background(Color(hex: 0xF1F5F9), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + .overlay( + RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input) + .stroke(Color(hex: 0xDDE3EA), lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + focusedField = .username + } + } + + private var passwordInput: some View { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Image(systemName: "lock") + .font(.system(size: AppMetrics.ControlSize.smallIcon)) + .foregroundStyle(AppDesign.textSecondary) + + Group { + if viewModel.showsPassword { + TextField("请输入密码", text: passwordBinding) + } else { + SecureField("请输入密码", text: passwordBinding) + } + } + .font(.system(size: AppMetrics.FontSize.body)) + .textFieldStyle(.plain) + .foregroundStyle(AppDesign.textPrimary) + .tint(AppDesign.primary) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .submitLabel(.go) + .focused($focusedField, equals: .password) + .onSubmit { + if viewModel.canSubmit && !viewModel.isLoading { + loginAction() + } + } + + Button { + viewModel.showsPassword.toggle() + } label: { + Image(viewModel.showsPassword ? "LoginPwdVisible" : "LoginPwdInvisible") + .resizable() + .frame(width: AppMetrics.ControlSize.passwordIcon, height: AppMetrics.ControlSize.passwordIcon) + .frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea) + } + .buttonStyle(.plain) + } + .padding(.horizontal, AppMetrics.Spacing.medium) + .frame(height: AppMetrics.ControlSize.inputHeight) + .background(Color(hex: 0xF1F5F9), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) + .overlay( + RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input) + .stroke(Color(hex: 0xDDE3EA), lineWidth: 1) + ) + .contentShape(Rectangle()) + .onTapGesture { + focusedField = .password + } + .accessibilityIdentifier("login.password") + } + + private var agreementRow: some View { + HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) { + Button { + viewModel.privacyChecked.toggle() + } label: { + Image(viewModel.privacyChecked ? "LoginCheckboxChecked" : "LoginCheckboxUnchecked") + .resizable() + .frame(width: AppMetrics.ControlSize.checkboxIcon, height: AppMetrics.ControlSize.checkboxIcon) + .frame(width: AppMetrics.ControlSize.checkboxTapArea, height: AppMetrics.ControlSize.checkboxTapArea) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("login.privacy") + + agreementText + .padding(.top, AppMetrics.Spacing.xxSmall) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + + private var agreementText: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 0) { + agreementPrefix + userAgreementButton + agreementSeparator + privacyPolicyButton + } + + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { + HStack(spacing: 0) { + agreementPrefix + userAgreementButton + } + HStack(spacing: 0) { + agreementSeparator + privacyPolicyButton + } + } + } + .font(.system(size: AppMetrics.FontSize.subheadline)) + } + + private var agreementPrefix: some View { + Text("已阅读并同意") + .foregroundStyle(Color(hex: 0x666666)) + } + + private var agreementSeparator: some View { + Text("与") + .foregroundStyle(Color(hex: 0x666666)) + } + + private var userAgreementButton: some View { + Button("《用户协议》") { + toastCenter.show("用户协议页面待接入") + } + .foregroundStyle(Color(hex: 0x208BFF)) + .accessibilityIdentifier("login.userAgreement") + } + + private var privacyPolicyButton: some View { + Button("《隐私政策》") { + toastCenter.show("隐私政策页面待接入") + } + .foregroundStyle(Color(hex: 0x208BFF)) + .accessibilityIdentifier("login.privacyPolicy") + } + + private var usernameBinding: Binding { + Binding( + get: { viewModel.username }, + set: { viewModel.username = $0 } + ) + } + + private var passwordBinding: Binding { + Binding( + get: { viewModel.password }, + set: { viewModel.password = $0 } + ) + } + + private var showsAgreementSheetBinding: Binding { + Binding( + get: { viewModel.showsAgreementSheet }, + set: { viewModel.showsAgreementSheet = $0 } + ) + } + + private var pendingAccountSelectionBinding: Binding { + Binding( + get: { viewModel.pendingAccountSelection }, + set: { viewModel.pendingAccountSelection = $0 } + ) + } +} + +/// 协议确认弹窗,负责在用户未勾选协议时引导确认。 +private struct LoginAgreementConsentSheet: View { + private static let compactDetent = PresentationDetent.height(240) + + let onOpenAgreement: (String) -> Void + let onAgreeAndContinue: () -> Void + + var body: some View { + VStack(spacing: AppMetrics.Spacing.sheet) { + Text("请先阅读并同意相关协议") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .frame(maxWidth: .infinity, alignment: .center) + + agreementDescription + + Button(action: onAgreeAndContinue) { + Text("同意并继续") + .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.sheetButtonHeight) + .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button)) + } + .buttonStyle(.plain) + .padding(.top, AppMetrics.Spacing.large) + .accessibilityIdentifier("login.agreement.continue") + } + .padding(.horizontal, AppMetrics.Spacing.large) + .padding(.top, AppMetrics.Spacing.sheet) + .padding(.bottom, AppMetrics.Spacing.mediumLarge) + .presentationDetents([Self.compactDetent]) + } + + private var agreementDescription: some View { + VStack(spacing: AppMetrics.Spacing.small) { + Text("为了保障你的账号安全和服务体验,请阅读并同意") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .multilineTextAlignment(.center) + + HStack(spacing: 0) { + agreementToken(title: "用户协议") + Text("和") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + agreementToken(title: "隐私政策") + } + } + .frame(maxWidth: .infinity) + } + + /// 构造协议链接文本按钮。 + private func agreementToken(title: String) -> some View { + Button { + onOpenAgreement(title) + } label: { + Text("《\(title)》") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + .foregroundStyle(Color(hex: 0x208BFF)) + } + .buttonStyle(.plain) + } +} + +#Preview { + LoginView() + .environment(AppSession()) + .environment(AccountContext()) + .environment(PermissionContext()) + .environment(ToastCenter()) + .environment(AuthAPI(client: APIClient())) + .environment(ProfileAPI(client: APIClient())) + .environment(AccountContextAPI(client: APIClient())) + .environment(AuthSessionCoordinator()) +} diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md new file mode 100644 index 0000000..e9c94d3 --- /dev/null +++ b/suixinkan/Features/Home/Home.md @@ -0,0 +1,36 @@ +# Home 模块业务逻辑 + +## 模块职责 + +Home 模块负责登录后的首页工作台,包括当前景区展示、工作状态、位置上报入口、快捷操作、常用应用和全部功能入口。 + +首页菜单来自 `PermissionContext` 中的角色权限。当前模块只同步首页壳和入口路由,入口背后的业务子模块后续逐个迁移。 + +## 菜单生成 + +`HomeViewModel` 从当前角色的权限树递归提取菜单: +- 当前角色 ID 存在但找不到时清空菜单。 +- 当前角色 ID 为空时使用第一个角色。 +- URI 按旧工程顺序排序。 +- 同义 URI 按 `HomeMenuRouter.menuAliasKey` 去重。 + +## 常用应用 + +`HomeCommonMenuStore` 使用 UserDefaults 保存常用应用 URI: +- 首次无配置时写入默认常用应用。 +- 读取时按当前角色权限过滤不可用 URI。 +- 添加和移除时按同义 URI 去重。 + +## 路由规则 + +`HomeMenuRouter` 将权限 URI 分为四类: +- `tab`:切换到订单或数据 Tab。 +- `destination`:进入已接入的本地页面,如个人信息、景区选择、全部功能。 +- `unsupported`:已知 iOS 不支持的入口,进入占位说明。 +- `placeholder`:未知或未迁移入口,记录诊断并进入占位说明。 + +`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转。 + +## 后续迁移 + +后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift new file mode 100644 index 0000000..a806bcd --- /dev/null +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -0,0 +1,62 @@ +// +// HomeMenuItem.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 首页菜单实体,表示一个由权限 URI 派生出来的可点击功能入口。 +struct HomeMenuItem: Equatable, Identifiable { + let title: String + let uri: String + let iconSrc: String? + + var id: String { + uri + } +} + +/// 首页可导航目标实体,表示首页菜单能进入的本地页面或占位页面。 +enum HomeRoute: Hashable { + case profileSpace + case scenicSelection + case moreFunctions + case modulePlaceholder(uri: String, title: String) +} + +/// 首页权限 URI 解析结果,区分 Tab 跳转、本地页面、已知不支持和未知占位。 +enum HomeMenuResolvedRoute: Equatable { + case tab(AppTab) + case destination(HomeRoute) + case unsupported(uri: String, title: String, reason: String) + case placeholder(uri: String, title: String) +} + +/// 未知首页路由记录实体,用于诊断后端新增但 iOS 尚未映射的 URI。 +struct UnknownHomeRouteRecord: Codable, Equatable { + let uri: String + let title: String + let firstSeenAt: Date + let lastSeenAt: Date + let count: Int +} + +/// 首页权限路由审计项,表示一个权限菜单和它的解析结果。 +struct HomePermissionRouteAuditEntry: Equatable { + let uri: String + let title: String + let route: HomeMenuResolvedRoute +} + +/// 首页权限路由审计结果,按可路由、已知不支持、未知三类聚合。 +struct HomePermissionRouteAudit: Equatable { + let routable: [HomePermissionRouteAuditEntry] + let unsupported: [HomePermissionRouteAuditEntry] + let unknown: [HomePermissionRouteAuditEntry] + + var hasUnknownRoutes: Bool { + !unknown.isEmpty + } +} diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift new file mode 100644 index 0000000..8e0a4da --- /dev/null +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -0,0 +1,412 @@ +// +// HomeMenuRouter.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import os + +/// 首页菜单路由器,将后端权限 URI 映射为 iOS 内部路由。 +enum HomeMenuRouter { + private static let titleMap: [String: String] = [ + "basic_info": "基本信息", + "space_settings": "空间设置", + "album_list": "相册管理", + "album_trailer": "相册预览上传", + "photographer_stats": "数据统计", + "photographer_orders": "订单管理", + "/scenic-order-manage": "景区订单", + "verification_order": "核销订单", + "wallet": "我的钱包", + "cloud_management": "相册云盘", + "cloud_storage_transit": "传输管理", + "asset_management": "素材管理", + "material_upload": "上传素材", + "task_management": "任务管理", + "task_management_editor": "任务管理", + "task_create": "发布任务", + "schedule_management": "日程管理", + "system_settings": "设置中心", + "message_center": "消息中心", + "checkin_points": "打卡点管理", + "sample_management": "样片管理", + "sample_upload": "上传样片", + "live_stream_management": "直播管理", + "live_album": "直播相册", + "scenicselection": "景区选择", + "scenicapplication": "景区申请", + "permission_apply": "权限申请", + "permission_apply_status": "权限申请状态", + "scenic_settlement": "景区结算", + "scenic_settlement_review": "结算审核", + "pm": "项目管理", + "pm_manager": "项目管理", + "project_edit": "项目编辑", + "location_report": "位置上报", + "registration_invitation": "注册邀请", + "store": "店铺管理", + "fly": "飞行管理", + "pilot_cert": "飞手认证", + "pilot_controller": "飞控", + "/scenic-queue": "排队管理", + "queue_management": "排队管理", + "operating-area": "运营区域", + "payment_collection": "立即收款", + "payment_qr": "收款码", + "payment_code": "收款码", + "deposit_order_detail": "押金订单详情", + "deposit_order": "押金订单详情", + "deposit_order_shooting_info": "押金拍摄信息", + "withdrawal_audit": "提现审核", + "location_report_history": "定位上报历史", + "photographer_invite": "邀请摄影师", + "invite_record": "邀请记录", + "more_functions": "更多功能" + ] + + /// 根据权限 URI 解析跳转目标。 + static func resolve(uri: String, title: String) -> HomeMenuResolvedRoute { + switch uri { + case "photographer_orders", "/scenic-order-manage": + return .tab(.orders) + case "verification_order": + return .tab(.orders) + case "photographer_stats": + return .tab(.statistics) + case "space_settings", "basic_info": + return .destination(.profileSpace) + case "scenicselection": + return .destination(.scenicSelection) + case "store", "more_functions": + return .destination(.moreFunctions) + case "fly", "pilot_controller": + return .unsupported( + uri: uri, + title: title.isEmpty ? self.title(for: uri) : title, + reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。" + ) + case "wallet", + "message_center", + "/scenic-queue", + "queue_management", + "payment_collection", + "payment_qr", + "payment_code", + "deposit_order_detail", + "deposit_order", + "deposit_order_shooting_info", + "withdrawal_audit", + "task_management", + "task_management_editor", + "task_create", + "schedule_management", + "checkin_points", + "pm", + "pm_manager", + "project_edit", + "cloud_management", + "cloud_storage_transit", + "album_list", + "album_trailer", + "asset_management", + "material_upload", + "sample_management", + "sample_upload", + "live_stream_management", + "live_album", + "scenicapplication", + "permission_apply", + "permission_apply_status", + "scenic_settlement", + "scenic_settlement_review", + "operating-area", + "location_report_history", + "location_report", + "registration_invitation", + "photographer_invite", + "invite_record", + "system_settings", + "pilot_cert": + let resolvedTitle = title.isEmpty ? self.title(for: uri) : title + return .destination(.modulePlaceholder(uri: uri, title: resolvedTitle)) + default: + return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title) + } + } + + /// 返回指定 URI 的默认标题。 + static func title(for uri: String) -> String { + titleMap[uri] ?? uri + } + + /// 在可用 URI 集合中选取规范形式。 + static func canonicalURI(for uri: String, availableURIs: Set) -> String { + if availableURIs.contains(uri) { + return uri + } + switch uri { + case "task_management": + return availableURIs.contains("task_management_editor") ? "task_management_editor" : uri + case "task_management_editor": + return availableURIs.contains("task_management") ? "task_management" : uri + case "registration_invitation": + return availableURIs.contains("photographer_invite") ? "photographer_invite" : uri + case "photographer_invite": + return availableURIs.contains("registration_invitation") ? "registration_invitation" : uri + case "pm": + return availableURIs.contains("pm_manager") ? "pm_manager" : uri + case "pm_manager": + return availableURIs.contains("pm") ? "pm" : uri + case "payment_code": + return availableURIs.contains("payment_qr") ? "payment_qr" : uri + default: + return uri + } + } + + /// 返回同义 URI 的去重键。 + static func menuAliasKey(for uri: String) -> String { + switch uri { + case "task_management", "task_management_editor": + return "task_management" + case "registration_invitation", "photographer_invite": + return "registration_invitation" + case "pm", "pm_manager", "project_edit": + return "pm" + case "payment_collection", "payment_qr", "payment_code": + return "payment_collection" + case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info": + return "deposit_order" + case "photographer_orders", "/scenic-order-manage": + return "photographer_orders" + case "/scenic-queue", "queue_management": + return "queue_management" + default: + return uri + } + } + + /// 返回首页展示标题,部分同义入口统一文案。 + static func displayTitle(for uri: String, fallback: String) -> String { + switch uri { + case "registration_invitation", "photographer_invite": + return "注册邀请" + case "location_report": + return "位置上报" + case "pm", "pm_manager": + return "项目管理" + case "space_settings": + return "空间设置" + case "task_management", "task_management_editor": + return "任务管理" + default: + return fallback + } + } +} + +/// 首页权限路由审计器,用于识别权限里哪些 URI 尚未映射。 +enum HomePermissionRouteAuditor { + /// 扫描当前角色权限并分类统计可路由、已知不支持和未知 URI。 + static func audit(permissions: [RolePermissionResponse], currentRoleId: Int?) -> HomePermissionRouteAudit { + let source: RolePermissionResponse? + if let currentRoleId { + source = permissions.first { $0.role.id == currentRoleId } + } else { + source = permissions.first + } + + let entries = uniquePermissionItems(from: source?.role.permission ?? []).map { item in + let title = item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name + return HomePermissionRouteAuditEntry( + uri: item.uri, + title: title, + route: HomeMenuRouter.resolve(uri: item.uri, title: title) + ) + } + + return HomePermissionRouteAudit( + routable: entries.filter { entry in + switch entry.route { + case .tab, .destination: + return true + case .unsupported, .placeholder: + return false + } + }, + unsupported: entries.filter { entry in + if case .unsupported = entry.route { return true } + return false + }, + unknown: entries.filter { entry in + if case .placeholder = entry.route { return true } + return false + } + ) + } + + /// 生成 Markdown 审计报告。 + static func markdownReport(audit: HomePermissionRouteAudit, generatedAt: Date = Date()) -> String { + var lines = [ + "# Home Permission Route Audit", + "", + "Generated: \(reportDateFormatter.string(from: generatedAt))", + "", + "Routable: \(audit.routable.count)", + "Unsupported: \(audit.unsupported.count)", + "Unknown: \(audit.unknown.count)", + "" + ] + + if audit.unknown.isEmpty { + lines.append("No unknown permission routes.") + } else { + lines.append("## Unknown Routes") + lines.append("") + lines.append("| URI | Title |") + lines.append("| --- | --- |") + lines.append(contentsOf: audit.unknown.map { "| \(escape($0.uri)) | \(escape($0.title)) |" }) + lines.append("") + lines.append("Action: map these URIs in `HomeMenuRouter`, confirm unsupported scope, or remove them before release.") + } + + if !audit.unsupported.isEmpty { + lines.append("") + lines.append("## Known Unsupported Routes") + lines.append("") + lines.append("| URI | Title | Reason |") + lines.append("| --- | --- | --- |") + lines.append(contentsOf: audit.unsupported.map { entry in + let reason: String + if case let .unsupported(_, _, value) = entry.route { + reason = value + } else { + reason = "" + } + return "| \(escape(entry.uri)) | \(escape(entry.title)) | \(escape(reason)) |" + }) + } + + return lines.joined(separator: "\n") + } + + private static var reportDateFormatter: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } + + /// 从权限树提取非空 URI,并按同义 URI 去重。 + private static func uniquePermissionItems(from items: [PermissionItem]) -> [PermissionItem] { + var seen = Set() + return flatten(items).filter { item in + guard !item.uri.isEmpty else { return false } + return seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted + } + } + + /// 递归展开权限树。 + private static func flatten(_ items: [PermissionItem]) -> [PermissionItem] { + items.flatMap { item in + [item] + flatten(item.children) + } + } + + /// 转义 Markdown 表格中的特殊字符。 + private static func escape(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "|", with: "\\|") + .replacingOccurrences(of: "\n", with: " ") + } +} + +/// 首页未知路由诊断工具,记录运行时无法识别的权限 URI。 +enum HomeRouteDiagnostics { + private static let logger = Logger(subsystem: "com.yuanzhixiang.suixinkan", category: "HomeRouting") + private static let defaultsKey = "home.routing.unknown.records" + private static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + /// 记录一个未知首页 URI。 + static func recordUnknown(uri: String, title: String) { + logger.warning("Unknown home menu uri: \(uri, privacy: .public), title: \(title, privacy: .public)") + var records = unknownRoutes() + let now = Date() + if let index = records.firstIndex(where: { $0.uri == uri }) { + let current = records[index] + records[index] = UnknownHomeRouteRecord( + uri: current.uri, + title: title.isEmpty ? current.title : title, + firstSeenAt: current.firstSeenAt, + lastSeenAt: now, + count: current.count + 1 + ) + } else { + records.append(UnknownHomeRouteRecord(uri: uri, title: title, firstSeenAt: now, lastSeenAt: now, count: 1)) + } + save(records) + } + + /// 读取所有未知首页路由记录。 + static func unknownRoutes() -> [UnknownHomeRouteRecord] { + guard let data = UserDefaults.standard.data(forKey: defaultsKey), + let records = try? JSONDecoder().decode([UnknownHomeRouteRecord].self, from: data) + else { return [] } + return records + } + + /// 生成未知首页路由 Markdown 报告。 + static func unknownRouteReport(generatedAt: Date = Date()) -> String { + let records = unknownRoutes().sorted { lhs, rhs in + if lhs.count != rhs.count { + return lhs.count > rhs.count + } + return lhs.lastSeenAt > rhs.lastSeenAt + } + var lines = [ + "# Home Route Diagnostics", + "", + "Generated: \(dateFormatter.string(from: generatedAt))", + "" + ] + + guard !records.isEmpty else { + lines.append("No unknown home routes recorded.") + return lines.joined(separator: "\n") + } + + lines.append("| URI | Title | Count | First Seen | Last Seen |") + lines.append("| --- | --- | ---: | --- | --- |") + lines.append(contentsOf: records.map { record in + "| \(escape(record.uri)) | \(escape(record.title)) | \(record.count) | \(dateFormatter.string(from: record.firstSeenAt)) | \(dateFormatter.string(from: record.lastSeenAt)) |" + }) + lines.append("") + lines.append("Action: each URI above must be mapped in `HomeMenuRouter`, confirmed as unsupported scope, or explicitly accepted before release.") + return lines.joined(separator: "\n") + } + + /// 清空未知首页路由记录。 + static func resetUnknownRoutes() { + UserDefaults.standard.removeObject(forKey: defaultsKey) + } + + /// 保存未知首页路由记录。 + private static func save(_ records: [UnknownHomeRouteRecord]) { + guard let data = try? JSONEncoder().encode(records) else { return } + UserDefaults.standard.set(data, forKey: defaultsKey) + } + + /// 转义 Markdown 表格中的特殊字符。 + private static func escape(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "|", with: "\\|") + .replacingOccurrences(of: "\n", with: " ") + } +} diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift new file mode 100644 index 0000000..fecb31d --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -0,0 +1,91 @@ +// +// HomeCommonMenuStore.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 首页常用应用存储服务,负责按当前权限过滤和持久化常用 URI。 +struct HomeCommonMenuStore { + static let defaultStorageKey = "home.common.menu.uris" + static let defaultBaselineKey = "home.common.menu.android.baseline.v2" + + private let defaults: UserDefaults + private let storageKey: String + private let baselineKey: String + + /// 初始化常用应用存储服务,并允许测试注入独立 UserDefaults。 + init( + defaults: UserDefaults = .standard, + storageKey: String = Self.defaultStorageKey, + baselineKey: String = Self.defaultBaselineKey + ) { + self.defaults = defaults + self.storageKey = storageKey + self.baselineKey = baselineKey + } + + /// 读取常用 URI,首次使用时写入默认值,并始终按当前权限过滤。 + func load(menuItems: [HomeMenuItem]) -> [String] { + let availableURIs = Set(menuItems.map(\.uri)) + if !defaults.bool(forKey: baselineKey) { + let defaults = defaultCommonURIs(availableURIs: availableURIs) + save(defaults) + self.defaults.set(true, forKey: baselineKey) + return defaults + } + + let saved = defaults.stringArray(forKey: storageKey) ?? [] + let filtered = unique(saved.compactMap { uri -> String? in + let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) + return availableURIs.contains(resolved) ? resolved : nil + }) + save(filtered) + return filtered + } + + /// 将指定 URI 加入常用应用,按别名避免重复添加。 + func add(_ uri: String, current: [String], menuItems: [HomeMenuItem]) -> [String] { + let availableURIs = Set(menuItems.map(\.uri)) + let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) + guard availableURIs.contains(resolved) else { return current } + guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else { + return current + } + let next = current + [resolved] + save(next) + return next + } + + /// 从常用应用中移除指定 URI,同义 URI 会一起移除。 + func remove(_ uri: String, current: [String]) -> [String] { + let aliasKey = HomeMenuRouter.menuAliasKey(for: uri) + let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey } + save(next) + return next + } + + /// 保存常用 URI。 + func save(_ uris: [String]) { + defaults.set(unique(uris), forKey: storageKey) + } + + /// 生成默认常用应用 URI。 + private func defaultCommonURIs(availableURIs: Set) -> [String] { + let preferred = ["registration_invitation", "location_report", "pm_manager", "pm"] + return unique(preferred.compactMap { uri in + let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) + return availableURIs.contains(resolved) ? resolved : nil + }) + } + + /// 按 URI 别名去重,保留首次出现的 URI。 + private func unique(_ uris: [String]) -> [String] { + var seen = Set() + return uris.filter { uri in + seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted + } + } +} diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..ab19f16 --- /dev/null +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -0,0 +1,116 @@ +// +// HomeViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 首页 ViewModel,根据当前角色权限构建首页菜单列表。 +final class HomeViewModel { + private(set) var menuItems: [HomeMenuItem] = [] + + /// 菜单排序权重,与旧工程和 Android 端保持一致。 + private let preferredOrder: [String] = [ + "space_settings", + "album_list", + "album_trailer", + "wallet", + "cloud_management", + "cloud_storage_transit", + "asset_management", + "material_upload", + "task_management", + "schedule_management", + "system_settings", + "message_center", + "checkin_points", + "sample_management", + "sample_upload", + "live_stream_management", + "scenicselection", + "scenicapplication", + "permission_apply", + "permission_apply_status", + "scenic_settlement", + "scenic_settlement_review", + "verification_order", + "live_album", + "pm", + "location_report", + "registration_invitation", + "store", + "fly", + "pilot_cert", + "task_management_editor", + "deposit_order_detail", + "deposit_order", + "pm_manager", + "pilot_controller", + "/scenic-queue", + "queue_management", + "operating-area", + "/scenic-order-manage", + "photographer_orders" + ] + + /// 从角色权限树扁平化出菜单项,按固定顺序排序并按 URI 别名去重。 + func buildMenus(from permissions: [RolePermissionResponse], currentRoleId: Int?) { + let source: RolePermissionResponse? + if let currentRoleId { + source = permissions.first { $0.role.id == currentRoleId } + } else { + source = permissions.first + } + + guard let source else { + menuItems = [] + return + } + + let permissionItems = flatten(source.role.permission) + var seen = Set() + var deduplicated: [PermissionItem] = [] + for item in permissionItems where !item.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted { + deduplicated.append(item) + } + } + + let preferredIndexMap = Dictionary(uniqueKeysWithValues: preferredOrder.enumerated().map { ($1, $0) }) + let sortedItems = deduplicated.sorted { lhs, rhs in + let lhsRank = preferredIndexMap[lhs.uri] ?? Int.max + let rhsRank = preferredIndexMap[rhs.uri] ?? Int.max + if lhsRank != rhsRank { + return lhsRank < rhsRank + } + let lhsIndex = deduplicated.firstIndex(where: { $0.id == lhs.id }) ?? Int.max + let rhsIndex = deduplicated.firstIndex(where: { $0.id == rhs.id }) ?? Int.max + return lhsIndex < rhsIndex + } + + menuItems = sortedItems.map { item in + HomeMenuItem( + title: item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name, + uri: item.uri, + iconSrc: item.iconSrc + ) + } + } + + /// 返回指定 URI 的展示标题。 + func title(for uri: String) -> String { + HomeMenuRouter.title(for: uri) + } + + /// 递归展开权限树。 + private func flatten(_ items: [PermissionItem]) -> [PermissionItem] { + items.flatMap { item in + [item] + flatten(item.children) + } + } +} diff --git a/suixinkan/Features/Home/Views/HomeIconCatalog.swift b/suixinkan/Features/Home/Views/HomeIconCatalog.swift new file mode 100644 index 0000000..3f10e3c --- /dev/null +++ b/suixinkan/Features/Home/Views/HomeIconCatalog.swift @@ -0,0 +1,87 @@ +// +// HomeIconCatalog.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 首页图标目录,负责为权限 URI 提供本地图标兜底。 +enum HomeIconCatalog { + /// 返回指定 URI 对应的 SF Symbols 名称。 + static func iconName(for uri: String) -> String { + switch uri { + case "space_settings": + "person.crop.square.fill" + case "album_list": + "photo.stack" + case "album_trailer": + "square.stack.3d.up.fill" + case "wallet": + "creditcard.fill" + case "cloud_management": + "icloud.fill" + case "cloud_storage_transit": + "arrow.left.arrow.right.circle.fill" + case "asset_management", "/scenic-order-manage": + "photo.on.rectangle.angled" + case "material_upload": + "square.and.arrow.up" + case "task_management", "task_management_editor": + "checklist" + case "schedule_management": + "calendar" + case "system_settings": + "gearshape.fill" + case "message_center": + "bell.fill" + case "checkin_points": + "mappin.and.ellipse" + case "sample_management": + "point.3.connected.trianglepath.dotted" + case "sample_upload": + "square.and.arrow.up.on.square" + case "live_stream_management": + "dot.radiowaves.left.and.right" + case "verification_order": + "checkmark.seal.fill" + case "live_album": + "play.rectangle.on.rectangle.fill" + case "pm", "pm_manager", "project_edit": + "circle.grid.2x2.fill" + case "location_report", "location_report_history": + "mappin.circle.fill" + case "registration_invitation", "photographer_invite": + "envelope.open.fill" + case "store": + "storefront.fill" + case "fly", "pilot_cert", "pilot_controller": + "paperplane.fill" + case "/scenic-queue", "queue_management": + "person.3.fill" + case "operating-area": + "map.fill" + case "scenicselection": + "location.magnifyingglass" + case "scenicapplication": + "doc.badge.plus" + case "permission_apply", "permission_apply_status": + "person.badge.key" + case "scenic_settlement", "scenic_settlement_review": + "checklist" + case "payment_collection", "payment_qr", "payment_code": + "qrcode" + case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info": + "doc.text.magnifyingglass" + case "withdrawal_audit": + "banknote.fill" + case "invite_record": + "list.bullet.rectangle.fill" + case "more_functions": + "ellipsis" + default: + "square.grid.2x2.fill" + } + } +} diff --git a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift new file mode 100644 index 0000000..0225ed9 --- /dev/null +++ b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift @@ -0,0 +1,213 @@ +// +// HomeMoreFunctionsView.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import SwiftUI + +/// 首页全部功能视图,展示常用应用和当前角色拥有的更多功能。 +struct HomeMoreFunctionsView: View { + @Environment(PermissionContext.self) private var permissionContext + @Environment(AppRouter.self) private var appRouter + @Environment(RouterPath.self) private var router + @Environment(\.dismiss) private var dismiss + + @State private var viewModel = HomeViewModel() + @State private var commonUris: [String] = [] + + private let commonMenuStore = HomeCommonMenuStore() + + private var commonItems: [HomeMenuItem] { + commonUris.compactMap(menuItem(for:)) + } + + private var moreItems: [HomeMenuItem] { + viewModel.menuItems.filter { item in + !isCommonURI(item.uri) + } + } + + var body: some View { + VStack(spacing: 0) { + topBar + + ScrollView { + VStack(alignment: .leading, spacing: 26) { + section(title: "常用应用", items: commonItems, isCommon: true) + section(title: "更多功能", items: moreItems, isCommon: false) + } + .padding(.horizontal, AppMetrics.Spacing.mediumLarge) + .padding(.top, AppMetrics.Spacing.xxLarge + 1) + .padding(.bottom, AppMetrics.Spacing.xxLarge) + } + .background(Color(hex: 0xF5F5F5)) + } + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .task { + rebuildMenus() + } + .onChange(of: permissionContext.currentRole?.id) { _, _ in + rebuildMenus() + } + .onChange(of: permissionContext.rolePermissions.count) { _, _ in + rebuildMenus() + } + } + + private var topBar: some View { + ZStack { + Text("全部功能") + .font(.system(size: AppMetrics.FontSize.title2 + 1, weight: .semibold)) + .foregroundStyle(Color(hex: 0x2C2C2C)) + .frame(maxWidth: .infinity) + + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(Color(hex: 0x111827)) + .frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea) + } + .buttonStyle(.plain) + + Spacer() + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + } + .frame(height: 62) + .background(.white) + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.black.opacity(0.06)) + .frame(height: 0.5) + } + } + + private func section(title: String, items: [HomeMenuItem], isCommon: Bool) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { + Text(title) + .font(.system(size: AppMetrics.FontSize.title, weight: .bold)) + .foregroundStyle(Color(hex: 0x2C2C2C)) + + appGrid(items: items, isCommon: isCommon) + } + } + + private func appGrid(items: [HomeMenuItem], isCommon: Bool) -> some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 3), spacing: AppMetrics.Spacing.mediumLarge) { + ForEach(items, id: \.uri) { item in + ZStack(alignment: .topTrailing) { + Button { + openMenu(item) + } label: { + VStack(spacing: AppMetrics.Spacing.mediumLarge + 1) { + menuIconView(for: item) + .frame(width: 34, height: 34) + + Text(item.title) + .font(.system(size: AppMetrics.FontSize.callout)) + .foregroundStyle(Color(hex: 0x252525)) + .lineLimit(1) + .minimumScaleFactor(0.78) + } + .frame(maxWidth: .infinity) + .frame(height: 112) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + + Button { + if isCommon { + commonUris = commonMenuStore.remove(item.uri, current: commonUris) + } else { + commonUris = commonMenuStore.add(item.uri, current: commonUris, menuItems: viewModel.menuItems) + } + } label: { + Image(systemName: isCommon ? "minus.circle.fill" : "plus.circle.fill") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(isCommon ? Color(hex: 0xFF1111) : AppDesign.primary) + .background(Color.white, in: Circle()) + } + .buttonStyle(.plain) + .offset(x: 9, y: -9) + } + .frame(maxWidth: .infinity) + .frame(height: 112) + } + } + } + + @ViewBuilder + private func menuIconView(for item: HomeMenuItem) -> some View { + if let src = item.iconSrc, + let url = URL(string: src), + !src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFit() + default: + fallbackMenuIcon(for: item.uri) + } + } + } else { + fallbackMenuIcon(for: item.uri) + } + } + + private func fallbackMenuIcon(for uri: String) -> some View { + Image(systemName: HomeIconCatalog.iconName(for: uri)) + .resizable() + .scaledToFit() + .foregroundStyle(AppDesign.primary) + .frame(width: 34, height: 34) + } + + private func menuItem(for uri: String) -> HomeMenuItem? { + let availableURIs = Set(viewModel.menuItems.map(\.uri)) + let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) + guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { + return nil + } + return HomeMenuItem( + title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title), + uri: resolvedUri, + iconSrc: existing.iconSrc + ) + } + + private func isCommonURI(_ uri: String) -> Bool { + let aliasKey = HomeMenuRouter.menuAliasKey(for: uri) + return commonUris.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey } + } + + private func openMenu(_ item: HomeMenuItem) { + switch HomeMenuRouter.resolve(uri: item.uri, title: item.title) { + case .tab(let tab): + appRouter.select(tab) + case .destination(let route): + if route == .moreFunctions { + return + } + router.navigate(to: .home(route)) + case .unsupported(let uri, let title, _): + router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title))) + case .placeholder(let uri, let title): + HomeRouteDiagnostics.recordUnknown(uri: uri, title: title) + router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title))) + } + } + + private func rebuildMenus() { + viewModel.buildMenus( + from: permissionContext.rolePermissions, + currentRoleId: permissionContext.currentRole?.id + ) + commonUris = commonMenuStore.load(menuItems: viewModel.menuItems) + } +} diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift new file mode 100644 index 0000000..f736378 --- /dev/null +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -0,0 +1,82 @@ +// +// HomeSupportViews.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import SwiftUI + +extension HomeRoute { + /// 将首页路由映射为真实页面或迁移占位页。 + @ViewBuilder + var destinationView: some View { + switch self { + case .profileSpace: + ProfileView() + case .scenicSelection: + HomeScenicSelectionView() + case .moreFunctions: + HomeMoreFunctionsView() + case let .modulePlaceholder(uri, title): + HomeMigrationModuleView(title: title, uri: uri) + } + } +} + +/// 首页迁移占位视图,用于承接尚未同步的功能入口。 +struct HomeMigrationModuleView: View { + let title: String + let uri: String + + var body: some View { + VStack(spacing: AppMetrics.Spacing.medium) { + Image(systemName: "square.grid.2x2") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + + Text(title) + .font(.system(size: AppMetrics.FontSize.title2, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + + Text(uri) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.center) + .padding(.horizontal, AppMetrics.Spacing.large) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: 0xF5F7FA)) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } +} + +/// 首页景区选择视图,允许用户切换当前账号上下文中的景区。 +struct HomeScenicSelectionView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(\.dismiss) private var dismiss + + var body: some View { + List(accountContext.scenicScopes) { scenic in + Button { + accountContext.selectScenic(id: scenic.id) + dismiss() + } label: { + HStack { + Text(scenic.name) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + if accountContext.currentScenic?.id == scenic.id { + Image(systemName: "checkmark") + .font(.system(size: AppMetrics.ControlSize.smallIcon, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + } + } + } + } + .navigationTitle("景区选择") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift new file mode 100644 index 0000000..4e5c182 --- /dev/null +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -0,0 +1,443 @@ +// +// HomeView.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Combine +import SwiftUI + +/// 首页主视图,展示景区、工作状态、快捷操作和权限菜单入口。 +struct HomeView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(PermissionContext.self) private var permissionContext + @Environment(AppRouter.self) private var appRouter + @Environment(RouterPath.self) private var router + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @State private var viewModel = HomeViewModel() + @State private var commonUris: [String] = [] + @State private var isOnline = false + @State private var reminderMinutes = 0 + @State private var secondsUntilReport = 0 + @State private var showOnlineDialog = false + @State private var showReminderDialog = false + @State private var showReportSuccess = false + + private let commonMenuStore = HomeCommonMenuStore() + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + private let minimalTopRoleIds: Set = [46, 47, 52, 53, 54] + + private var contentMaxWidth: CGFloat { + horizontalSizeClass == .regular ? 760 : .infinity + } + + private var currentRoleId: Int? { + permissionContext.currentRole?.id + } + + private var isStoreManager: Bool { + currentRoleId == 46 + } + + private var shouldShowWorkStatus: Bool { + guard let currentRoleId else { return true } + return !minimalTopRoleIds.contains(currentRoleId) + } + + private var currentScenicName: String { + accountContext.currentScenic?.name ?? "请选择景区" + } + + private var countdownDisplay: String { + let hours = secondsUntilReport / 3_600 + let minutes = (secondsUntilReport % 3_600) / 60 + let seconds = secondsUntilReport % 60 + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" + } + + private var reminderText: String { + reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟" + } + + private var tileHeight: CGFloat { + horizontalSizeClass == .regular ? 118 : 102 + } + + private var displayMenuItems: [HomeMenuItem] { + let selected = commonUris.compactMap(menuItemForHomeEntry(uri:)) + let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected + return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)] + } + + var body: some View { + VStack(spacing: 0) { + topBar + + ScrollView { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + if shouldShowWorkStatus { + statusCard + locationReportCard + quickActionsRow + } + + if isStoreManager, let store = accountContext.currentStore { + storeCard(store) + .padding(.bottom, AppMetrics.Spacing.xSmall) + } + + Text("常用应用") + .font(.system(size: AppMetrics.FontSize.body, weight: .bold)) + .foregroundStyle(Color(hex: 0x333333)) + .padding(.top, shouldShowWorkStatus ? AppMetrics.Spacing.xxSmall : AppMetrics.Spacing.xSmall) + .padding(.bottom, AppMetrics.Spacing.xxSmall) + + appGrid + } + .frame(maxWidth: contentMaxWidth, alignment: .topLeading) + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.top, AppMetrics.Spacing.xxLarge) + .padding(.bottom, AppMetrics.Spacing.pageVertical) + .frame(maxWidth: .infinity) + } + .background(Color(hex: 0xF5F5F5)) + } + .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) + .toolbar(.hidden, for: .navigationBar) + .task { + rebuildMenusFromCurrentContext() + } + .onReceive(timer) { _ in + guard isOnline, secondsUntilReport > 0 else { return } + secondsUntilReport -= 1 + } + .onChange(of: permissionContext.currentRole?.id) { _, _ in + rebuildMenusFromCurrentContext() + } + .onChange(of: permissionContext.rolePermissions.count) { _, _ in + rebuildMenusFromCurrentContext() + } + .alert("切换在线状态", isPresented: $showOnlineDialog) { + Button("取消", role: .cancel) {} + Button("确定") { + isOnline.toggle() + if isOnline { + secondsUntilReport = 7_200 + } + } + } message: { + Text(isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。") + } + .confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) { + ForEach([0, 5, 10, 15, 30], id: \.self) { minute in + Button(minute == 0 ? "不提醒" : "\(minute)分钟") { + reminderMinutes = minute + } + } + Button("取消", role: .cancel) {} + } + .alert("上报成功", isPresented: $showReportSuccess) { + Button("知道了", role: .cancel) {} + } message: { + Text("上报已成功,距离下次上报时间 \(countdownDisplay)。") + } + } + + private var topBar: some View { + HStack { + Button { + router.navigate(to: .home(.scenicSelection)) + } label: { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Text(currentScenicName) + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(Color(hex: 0x333333)) + .lineLimit(1) + .minimumScaleFactor(0.8) + Image(systemName: "chevron.down") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .bold)) + .foregroundStyle(.black) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .frame(height: 78) + .background(.white) + } + + private var statusCard: some View { + HStack(spacing: AppMetrics.Spacing.small) { + Button { + showOnlineDialog = true + } label: { + Text(isOnline ? "在线" : "离线") + .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) + .foregroundStyle(isOnline ? Color(hex: 0xF0FDF4) : Color(hex: 0x7B8EAA)) + .padding(.horizontal, AppMetrics.Spacing.small) + .padding(.vertical, AppMetrics.Spacing.xxSmall + 2) + .background(isOnline ? Color(hex: 0x22C55E) : Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.plain) + + Spacer(minLength: AppMetrics.Spacing.xxSmall) + + Label { + Text(countdownDisplay) + .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) + } icon: { + Image(systemName: "clock") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + } + .foregroundStyle(AppDesign.primary) + .lineLimit(1) + .minimumScaleFactor(0.85) + + Spacer(minLength: AppMetrics.Spacing.xxSmall) + + Button { + showReminderDialog = true + } label: { + Label { + Text(reminderText) + .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) + } icon: { + Image(systemName: "bell.fill") + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) + } + .foregroundStyle(AppDesign.primary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .buttonStyle(.plain) + } + .padding(15) + .frame(minHeight: 84) + .frame(maxWidth: .infinity) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var locationReportCard: some View { + HStack(spacing: AppMetrics.Spacing.medium) { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { + HStack(spacing: AppMetrics.Spacing.xSmall) { + Image(systemName: "arrow.up.square.fill") + .font(.system(size: 25, weight: .bold)) + .foregroundStyle(AppDesign.primary) + Text("立即上报") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(Color(hex: 0x333333)) + } + + Text("您已进入打卡范围") + .font(.system(size: 15)) + .foregroundStyle(Color(hex: 0x999999)) + } + + Spacer() + + Button { + secondsUntilReport = 7_200 + showReportSuccess = true + } label: { + Image(systemName: "hand.tap.fill") + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 92, height: 92) + .background( + LinearGradient( + colors: [Color(hex: 0x0073FF), Color(hex: 0x5CA8FF)], + startPoint: .top, + endPoint: .bottom + ), + in: Circle() + ) + } + .buttonStyle(.plain) + .accessibilityLabel("上报位置") + } + .padding(15) + .frame(minHeight: 132) + .frame(maxWidth: .infinity) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var quickActionsRow: some View { + HStack(spacing: AppMetrics.Spacing.small) { + quickAction(icon: "qrcode", title: "立即收款") { + openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款")) + } + + quickAction(icon: "checklist.checked", title: "提交任务") { + openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务")) + } + + quickAction(icon: isOnline ? "wifi" : "wifi.slash", title: isOnline ? "在线" : "离线", active: isOnline) { + showOnlineDialog = true + } + } + } + + private func quickAction(icon: String, title: String, active: Bool = false, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: AppMetrics.Spacing.xSmall) { + Image(systemName: icon) + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(height: 34) + + Text(title) + .font(.system(size: AppMetrics.FontSize.body)) + .foregroundStyle(Color.black) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + .frame(height: tileHeight) + .background(active ? Color(hex: 0xE3F2FD) : .white, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + + private func storeCard(_ store: BusinessScope) -> some View { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Text(store.name) + .font(.system(size: AppMetrics.FontSize.title2, weight: .bold)) + .foregroundStyle(.black) + .lineLimit(1) + + if let scenic = accountContext.currentScenic { + Text(scenic.name) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(Color(hex: 0x7B8EAA)) + .lineLimit(2) + } + } + + Spacer() + + Text("营业中") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(Color(hex: 0x22C55E)) + .padding(.horizontal, AppMetrics.Spacing.xSmall + 2) + .padding(.vertical, AppMetrics.Spacing.xxxSmall) + .background(Color(hex: 0xF0FDF4), in: RoundedRectangle(cornerRadius: 4)) + } + .padding(AppMetrics.Spacing.medium) + .frame(maxWidth: .infinity) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + + private var appGrid: some View { + LazyVGrid(columns: menuColumns, spacing: 15) { + ForEach(displayMenuItems) { item in + Button { + openMenu(item) + } label: { + VStack(spacing: AppMetrics.Spacing.small) { + menuIconView(for: item) + .frame(width: 30, height: 30) + Text(item.title) + .font(.system(size: AppMetrics.FontSize.body)) + .foregroundStyle(Color(hex: 0x4B5563)) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .frame(maxWidth: .infinity) + .frame(height: tileHeight) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + } + + private var menuColumns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: 15), count: 3) + } + + @ViewBuilder + private func menuIconView(for item: HomeMenuItem) -> some View { + if let src = item.iconSrc, + let url = URL(string: src), + !src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + default: + fallbackMenuIcon(for: item.uri) + } + } + } else { + fallbackMenuIcon(for: item.uri) + } + } + + private func fallbackMenuIcon(for uri: String) -> some View { + Image(systemName: iconName(for: uri)) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(width: 30, height: 30) + } + + private func iconName(for uri: String) -> String { + HomeIconCatalog.iconName(for: uri) + } + + private func menuItemForHomeEntry(uri: String) -> HomeMenuItem? { + let availableURIs = Set(viewModel.menuItems.map(\.uri)) + let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) + guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { + return nil + } + return HomeMenuItem( + title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title), + uri: resolvedUri, + iconSrc: existing.iconSrc + ) + } + + private func openMenu(_ item: HomeMenuItem) { + openRoute(HomeMenuRouter.resolve(uri: item.uri, title: item.title)) + } + + private func openRoute(_ route: HomeMenuResolvedRoute) { + switch route { + case .tab(let tab): + appRouter.select(tab) + case .destination(let route): + router.navigate(to: .home(route)) + case .unsupported(let uri, let title, _): + router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title))) + case .placeholder(let uri, let title): + HomeRouteDiagnostics.recordUnknown(uri: uri, title: title) + router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title))) + } + } + + private func rebuildMenusFromCurrentContext() { + viewModel.buildMenus( + from: permissionContext.rolePermissions, + currentRoleId: permissionContext.currentRole?.id + ) + commonUris = commonMenuStore.load(menuItems: viewModel.menuItems) + } +} + +#Preview { + NavigationStack { + HomeView() + .environment(AccountContext()) + .environment(PermissionContext()) + .environment(AppRouter()) + .environment(RouterPath()) + } +} diff --git a/suixinkan/Features/Main/Main.md b/suixinkan/Features/Main/Main.md new file mode 100644 index 0000000..93a66d4 --- /dev/null +++ b/suixinkan/Features/Main/Main.md @@ -0,0 +1,40 @@ +# Main 模块业务逻辑 + +## 模块职责 + +Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。 + +主界面由 `MainTabsView` 承载: +- 使用 `TabView` 展示首页、订单、数据、我的四个 Tab。 +- 每个 Tab 内部都包裹独立的 `NavigationStack`。 +- 每个 Tab 的导航路径由 `AppRouter` 单独保存。 + +## Tab 结构 + +当前 Tab 来自 `AppTab`: +- `home`:首页 +- `orders`:订单 +- `statistics`:数据 +- `profile`:我的 + +`HomeRootView` 已接入真实的 `HomeView`,`ProfileRootView` 已接入真实的 `ProfileView`。订单和数据 Tab 当前仍使用 `PlaceholderTabRootView`,用于保留入口和验证 Tab 内导航。 + +## 导航流程 + +1. `MainTabsView` 从 Environment 读取 `AppRouter`。 +2. `TabView` 使用 `appRouter.selectedTab` 作为选中状态。 +3. 每个 Tab 创建自己的 `NavigationStack(path:)`。 +4. 路径绑定来自 `appRouter.binding(for:)`。 +5. 占位页点击“打开详情”时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。 +6. `navigationDestination` 根据 `AppRoute` 展示详情页。 +7. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。 + +## 后续迁移规则 + +迁移新页面时: +- 优先替换对应 Tab 的 RootView。 +- 保持每个 Tab 自己的 `NavigationStack`。 +- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。 +- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。 +- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。 +- 真实业务页面接入后,应同步补充该模块文档和单元测试。 diff --git a/suixinkan/Features/Main/Views/MainTabsView.swift b/suixinkan/Features/Main/Views/MainTabsView.swift new file mode 100644 index 0000000..428534f --- /dev/null +++ b/suixinkan/Features/Main/Views/MainTabsView.swift @@ -0,0 +1,38 @@ +// +// MainTabsView.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import SwiftUI + +/// 主 Tab 容器视图,为每个 Tab 创建独立的 NavigationStack。 +struct MainTabsView: View { + @Environment(AppRouter.self) private var appRouter + + var body: some View { + @Bindable var appRouter = appRouter + + TabView(selection: $appRouter.selectedTab) { + ForEach(AppTab.allCases) { tab in + NavigationStack(path: appRouter.binding(for: tab)) { + tab.rootView + .navigationTitle(tab.title) + .navigationDestination(for: AppRoute.self) { route in + route.destinationView + .toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar) + } + } + .environment(appRouter.router(for: tab)) + .tabItem { tab.label } + .tag(tab) + } + } + } +} + +#Preview { + MainTabsView() + .environment(AppRouter()) +} diff --git a/suixinkan/Features/Main/Views/PlaceholderRootViews.swift b/suixinkan/Features/Main/Views/PlaceholderRootViews.swift new file mode 100644 index 0000000..c43376e --- /dev/null +++ b/suixinkan/Features/Main/Views/PlaceholderRootViews.swift @@ -0,0 +1,77 @@ +// +// PlaceholderRootViews.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import SwiftUI + +/// 首页根视图,占位承载后续首页迁移内容。 +struct HomeRootView: View { + var body: some View { + HomeView() + } +} + +/// 订单根视图,占位承载后续订单模块迁移内容。 +struct OrdersRootView: View { + var body: some View { + PlaceholderTabRootView(title: "订单", systemImage: "doc.text") + } +} + +/// 数据根视图,占位承载后续统计模块迁移内容。 +struct StatisticsRootView: View { + var body: some View { + PlaceholderTabRootView(title: "数据", systemImage: "chart.bar") + } +} + +/// 我的根视图,当前承载个人信息页面。 +struct ProfileRootView: View { + var body: some View { + ProfileView() + } +} + +/// 通用 Tab 占位视图,用于未迁移模块的临时入口。 +private struct PlaceholderTabRootView: View { + @Environment(RouterPath.self) private var router + + let title: String + let systemImage: String + + var body: some View { + VStack(spacing: 16) { + Image(systemName: systemImage) + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(.tint) + + Text(title) + .font(.title2.weight(.semibold)) + + Button { + router.navigate(to: .placeholder(title: "\(title)详情")) + } label: { + Label("打开详情", systemImage: "chevron.right") + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + } +} + +/// 通用占位详情页,用于验证 Tab 内 NavigationStack 跳转。 +struct PlaceholderDetailView: View { + let title: String + + var body: some View { + Text(title) + .font(.title3.weight(.medium)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .navigationTitle(title) + } +} diff --git a/suixinkan/Features/Profile/API/ProfileAPI.swift b/suixinkan/Features/Profile/API/ProfileAPI.swift new file mode 100644 index 0000000..aaa5ce7 --- /dev/null +++ b/suixinkan/Features/Profile/API/ProfileAPI.swift @@ -0,0 +1,66 @@ +// +// ProfileAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。 +final class ProfileAPI { + @ObservationIgnored private let client: APIClient + + /// 初始化个人信息 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 拉取当前登录用户的基础资料。 + func userInfo() async throws -> UserInfoResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/userinfo" + ) + ) + } + + /// 拉取当前登录用户的实名认证状态。 + func realNameInfo() async throws -> RealNameInfoResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/real-name/info" + ) + ) + } + + /// 更新用户昵称、密码或头像地址。 + func updateUserInfo(nickname: String? = nil, password: String? = nil, avatar: String? = nil) async throws { + let request = UpdateInfoRequest(nickname: nickname, password: password, avatar: avatar) + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/userinfo-update", + body: request + ) + ) + } + + /// 单独更新用户头像 URL,供 OSS 上传完成后回写服务端。 + func updateUserAvatarURL(_ fileURL: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/userinfo-update-avatar-url", + queryItems: [URLQueryItem(name: "file_url", value: fileURL)] + ) + ) + } +} + +extension ProfileAPI: UserProfileServing {} diff --git a/suixinkan/Features/Profile/Models/ProfileModels.swift b/suixinkan/Features/Profile/Models/ProfileModels.swift new file mode 100644 index 0000000..b075c68 --- /dev/null +++ b/suixinkan/Features/Profile/Models/ProfileModels.swift @@ -0,0 +1,142 @@ +// +// ProfileModels.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation + +/// 用户资料响应实体,表示“我的”页面展示的账号基础信息。 +struct UserInfoResponse: Decodable, Equatable { + let avatar: String + let realName: String + let phone: String + let nickname: String + let roleName: String + let status: Int + let statusName: String + + /// 用户资料响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case avatar + case realName = "real_name" + case phone + case nickname + case roleName = "role_name" + case status + case statusName = "status_name" + } + + /// 创建用户资料实体,主要用于本地更新和预览默认值。 + init( + avatar: String = "", + realName: String = "", + phone: String = "", + nickname: String = "", + roleName: String = "", + status: Int = 0, + statusName: String = "" + ) { + self.avatar = avatar + self.realName = realName + self.phone = phone + self.nickname = nickname + self.roleName = roleName + self.status = status + self.statusName = statusName + } + + /// 自定义解码,兼容后端字段类型不稳定的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + avatar = try container.decodeLossyString(forKey: .avatar) + realName = try container.decodeLossyString(forKey: .realName) + phone = try container.decodeLossyString(forKey: .phone) + nickname = try container.decodeLossyString(forKey: .nickname) + roleName = try container.decodeLossyString(forKey: .roleName) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusName = try container.decodeLossyString(forKey: .statusName) + } +} + +/// 用户资料更新请求实体,表示昵称、密码和头像修改参数。 +struct UpdateInfoRequest: Encodable { + let nickname: String? + let password: String? + let avatar: String? +} + +/// 实名认证响应实体,包裹当前用户的认证详情。 +struct RealNameInfoResponse: Decodable, Equatable { + let realNameInfo: RealNameInfo? + + /// 实名认证响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case realNameInfo = "real_name_info" + } +} + +/// 实名认证信息实体,表示当前用户实名审核状态和失败原因。 +struct RealNameInfo: Decodable, Equatable { + let realName: String + let auditStatus: Int + let auditStatusText: String? + let rejectReason: String? + + /// 实名认证信息的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case realName = "real_name" + case auditStatus = "audit_status" + case auditStatusText = "audit_status_text" + case rejectReason = "reject_reason" + } + + /// 自定义解码,兼容审核状态字段类型不稳定的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + realName = try container.decodeLossyString(forKey: .realName) + auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0 + auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText) + rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason) + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan/Features/Profile/Profile.md b/suixinkan/Features/Profile/Profile.md new file mode 100644 index 0000000..88b90bd --- /dev/null +++ b/suixinkan/Features/Profile/Profile.md @@ -0,0 +1,75 @@ +# Profile 模块业务逻辑 + +## 模块职责 + +Profile 模块负责“我的/个人信息”页面,包括用户资料展示、昵称编辑、密码修改、实名认证状态展示和退出登录。 + +该模块聚焦个人资料业务: +- 拉取用户基础资料。 +- 拉取实名认证信息。 +- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。 +- 修改昵称。 +- 修改密码。 +- 退出登录入口。 + +登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。 + +## 核心对象 + +- `ProfileView`:个人信息页 UI,负责展示资料、编辑入口、密码弹窗和退出确认。 +- `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。 +- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。 +- `UserInfoResponse`:用户基础资料。 +- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。 +- `UpdateInfoRequest`:昵称、密码或头像更新请求体。 + +## 加载流程 + +1. `ProfileView.task` 进入页面时调用 `reloadProfile(showToast: false)`。 +2. 下拉刷新时调用 `reloadProfile(showToast: true)`。 +3. `ProfileViewModel.reload` 并发请求: + - `ProfileAPI.userInfo` + - `ProfileAPI.realNameInfo` +4. 请求成功后更新 `userInfo` 和 `realNameInfo`。 +5. `ProfileView` 将最新 `userInfo` 交给 `AuthSessionCoordinator.refreshCachedProfile`。 +6. 协调器更新 `AccountContext.profile`,并同步刷新账号快照。 + +## 昵称编辑流程 + +1. 用户点击编辑按钮。 +2. `ProfileViewModel.beginEditing` 进入编辑状态,并把当前昵称写入编辑框。 +3. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。 +4. 昵称未变化时直接退出编辑状态。 +5. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。 +6. 接口成功后本地更新 `userInfo.nickname`。 +7. `ProfileView` 刷新全局账号资料和账号快照。 + +## 密码修改流程 + +1. 用户打开 `PasswordUpdateSheet`。 +2. 输入新密码并点击完成。 +3. `ProfileViewModel.updatePassword` 校验密码至少 6 位。 +4. 校验通过后调用 `ProfileAPI.updateUserInfo(password:)`。 +5. 成功后关闭弹窗并展示 Toast。 + +密码不会写入本地缓存。 + +## 退出登录流程 + +1. 用户点击退出登录。 +2. `ProfileView` 展示确认弹窗。 +3. 用户确认后调用 `AuthSessionCoordinator.logout`。 +4. 协调器清空正式 token、账号快照、账号上下文、路由栈和 Toast。 +5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。 +6. 上次手机号和协议状态等非敏感偏好保留。 + +## 状态展示规则 + +- 昵称为空时展示“未设置昵称”。 +- 真实姓名、手机号和账号状态为空时展示 `--`。 +- 实名认证状态: + - `auditStatus == 2`:已实名认证。 + - `auditStatus == 3`:审核不通过。 + - 其他状态:审核中。 + - 无实名认证信息时:点击去实名认证。 + diff --git a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..a8e4e2a --- /dev/null +++ b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift @@ -0,0 +1,157 @@ +// +// ProfileViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。 +final class ProfileViewModel { + var userInfo: UserInfoResponse? + var realNameInfo: RealNameInfo? + var isLoading = false + var isSaving = false + var isEditingProfile = false + var editingNickname = "" + + var displayNickname: String { + nonEmpty(userInfo?.nickname) ?? "未设置昵称" + } + + var displayRealName: String { + nonEmpty(userInfo?.realName) ?? "--" + } + + var displayPhone: String { + nonEmpty(userInfo?.phone) ?? "--" + } + + var displayAvatarURL: String { + nonEmpty(userInfo?.avatar) ?? "" + } + + var accountStatusText: String { + nonEmpty(userInfo?.statusName) ?? "--" + } + + var realNameStatusText: String { + guard let realNameInfo else { return "点击去实名认证" } + switch realNameInfo.auditStatus { + case 2: + return "已实名认证" + case 3: + return "审核不通过" + default: + return "审核中" + } + } + + /// 重新加载用户资料和实名认证状态。 + func reload(api: ProfileAPI) async throws { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + + async let user = api.userInfo() + async let realName = api.realNameInfo() + let result = try await (user, realName) + + userInfo = result.0 + realNameInfo = result.1.realNameInfo + } + + /// 进入昵称编辑状态,并把当前昵称填入编辑框。 + func beginEditing() { + editingNickname = displayNickname == "未设置昵称" ? "" : displayNickname + isEditingProfile = true + } + + /// 退出昵称编辑状态并清空编辑输入。 + func cancelEditing() { + editingNickname = "" + isEditingProfile = false + } + + /// 保存昵称修改,并同步更新本地 userInfo。 + func saveProfile(api: ProfileAPI) async throws { + let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !nextNickname.isEmpty else { + throw ProfileValidationError.emptyNickname + } + + let nicknameChanged = nextNickname != displayNickname + guard nicknameChanged else { + cancelEditing() + return + } + + guard !isSaving else { return } + isSaving = true + defer { isSaving = false } + + try await api.updateUserInfo(nickname: nextNickname) + var nextUser = userInfo ?? UserInfoResponse() + nextUser = UserInfoResponse( + avatar: nextUser.avatar, + realName: nextUser.realName, + phone: nextUser.phone, + nickname: nextNickname, + roleName: nextUser.roleName, + status: nextUser.status, + statusName: nextUser.statusName + ) + userInfo = nextUser + cancelEditing() + } + + /// 修改用户密码,并校验最小长度。 + func updatePassword(_ password: String, api: ProfileAPI) async throws { + let nextPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + guard nextPassword.count >= 6 else { + throw ProfileValidationError.shortPassword + } + + guard !isSaving else { return } + isSaving = true + defer { isSaving = false } + + try await api.updateUserInfo(password: nextPassword) + } + + /// 将最新 userInfo 合成为全局 AccountProfile,保留已有 userId 等兜底字段。 + func accountProfileFallback(_ fallback: AccountProfile?) -> AccountProfile? { + guard let userInfo else { return fallback } + return AccountProfile( + userId: fallback?.userId ?? "", + displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称", + phone: nonEmpty(userInfo.phone) ?? fallback?.phone, + avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL + ) + } + + /// 去除空白字符后返回非空字符串。 + private func nonEmpty(_ value: String?) -> String? { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } +} + +/// 个人信息页校验错误实体,表示昵称和密码输入不符合要求。 +enum ProfileValidationError: LocalizedError { + case emptyNickname + case shortPassword + + var errorDescription: String? { + switch self { + case .emptyNickname: + "请输入昵称" + case .shortPassword: + "密码至少 6 位" + } + } +} diff --git a/suixinkan/Features/Profile/Views/ProfileView.swift b/suixinkan/Features/Profile/Views/ProfileView.swift new file mode 100644 index 0000000..b11d871 --- /dev/null +++ b/suixinkan/Features/Profile/Views/ProfileView.swift @@ -0,0 +1,636 @@ +// +// ProfileView.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import SwiftUI + +/// 个人信息页视图,展示用户资料、账号状态、实名认证状态和退出登录入口。 +struct ProfileView: View { + @Environment(AppSession.self) private var appSession + @Environment(AccountContext.self) private var accountContext + @Environment(PermissionContext.self) private var permissionContext + @Environment(ScenicSpotContext.self) private var scenicSpotContext + @Environment(AppRouter.self) private var appRouter + @Environment(ProfileAPI.self) private var profileAPI + @Environment(ToastCenter.self) private var toastCenter + @Environment(AuthSessionCoordinator.self) private var authSessionCoordinator + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @State private var viewModel = ProfileViewModel() + @State private var showsPasswordSheet = false + @State private var showsLogoutConfirm = false + @FocusState private var nicknameFocused: Bool + + private var contentMaxWidth: CGFloat { + horizontalSizeClass == .regular ? 720 : .infinity + } + + var body: some View { + ScrollView { + VStack(spacing: 22) { + header + infoCard + logoutButton + } + .padding(.horizontal, 19) + .padding(.top, 24) + .padding(.bottom, 24) + .frame(maxWidth: contentMaxWidth) + .frame(maxWidth: .infinity) + } + .background(profileBackground.ignoresSafeArea()) + .navigationTitle("个人信息") + .navigationBarTitleDisplayMode(.inline) + .task { + await reloadProfile(showToast: false) + } + .refreshable { + await reloadProfile(showToast: true) + } + .sheet(isPresented: $showsPasswordSheet) { + PasswordUpdateSheet { password in + await updatePassword(password) + } + } + .confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) { + Button("退出登录", role: .destructive) { + authSessionCoordinator.logout( + appSession: appSession, + accountContext: accountContext, + permissionContext: permissionContext, + scenicSpotContext: scenicSpotContext, + appRouter: appRouter, + toastCenter: toastCenter + ) + } + Button("取消", role: .cancel) {} + } + .overlay { + if viewModel.isLoading && viewModel.userInfo == nil { + ProgressView() + .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.white.opacity(0.35)) + } + } + .onChange(of: viewModel.isEditingProfile) { _, isEditing in + guard isEditing else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + nicknameFocused = true + } + } + } + + private var profileBackground: some View { + LinearGradient( + colors: [ + Color(hex: 0xF7FAFF), + Color(hex: 0xF3F7FD), + Color.white + ], + startPoint: .top, + endPoint: .bottom + ) + } + + private var header: some View { + HStack(alignment: .center, spacing: 14) { + avatarImage + + VStack(alignment: .leading, spacing: 8) { + if viewModel.isEditingProfile { + TextField("请输入昵称", text: nicknameBinding) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(Color(hex: 0x252525)) + .tint(AppDesign.primary) + .textFieldStyle(.plain) + .focused($nicknameFocused) + .submitLabel(.done) + .padding(.horizontal, 10) + .frame(height: 42) + .background(.white, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(AppDesign.primary, lineWidth: 1) + } + .onSubmit { + Task { await saveProfileEdits() } + } + } else { + Text(viewModel.displayNickname) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(Color(hex: 0x252525)) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + + Text("UID: \(accountContext.profile?.userId.isEmpty == false ? accountContext.profile?.userId ?? "--" : "--")") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color(hex: 0x606A7A)) + .lineLimit(1) + } + .layoutPriority(1) + + Spacer(minLength: 0) + + Button { + if viewModel.isEditingProfile { + Task { await saveProfileEdits() } + } else { + viewModel.beginEditing() + } + } label: { + ZStack { + if viewModel.isSaving && viewModel.isEditingProfile { + ProgressView() + .tint(.white) + .scaleEffect(0.82) + } else { + Image(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil") + .font(.system(size: 18, weight: .semibold)) + } + } + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .background( + LinearGradient( + colors: [Color(hex: 0x238BFF), Color(hex: 0x006BFF)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: Circle() + ) + .shadow(color: Color(hex: 0x0073FF, alpha: 0.24), radius: 10, x: 0, y: 5) + } + .buttonStyle(.plain) + .disabled(viewModel.isSaving) + .opacity(viewModel.isSaving ? 0.6 : 1) + .accessibilityLabel(viewModel.isEditingProfile ? "完成编辑" : "编辑个人信息") + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18) + .padding(.vertical, 21) + .frame(minHeight: 132) + .background { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .fill(.white) + ProfileHeaderWaveShape() + .fill( + LinearGradient( + colors: [ + Color(hex: 0xBFDFFF, alpha: 0.18), + Color(hex: 0x8EC4FF, alpha: 0.28) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ProfileHeaderWaveShape(phase: 0.35) + .fill(Color(hex: 0xE4F1FF, alpha: 0.62)) + } + .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .stroke(Color(hex: 0xE4ECF7), lineWidth: 1) + } + .shadow(color: Color(hex: 0xC8D7EA, alpha: 0.32), radius: 16, x: 0, y: 8) + } + + private var avatarImage: some View { + Button { + toastCenter.show("头像上传待接入阿里云 OSS") + } label: { + AsyncAvatarImage(urlString: viewModel.displayAvatarURL) + .frame(width: 86, height: 86) + .clipShape(Circle()) + .overlay { + Circle() + .stroke(.white, lineWidth: 3) + } + .overlay(alignment: .bottomTrailing) { + Image(systemName: "camera.fill") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 26, height: 26) + .background(AppDesign.primary, in: Circle()) + .overlay { + Circle().stroke(.white, lineWidth: 2) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("头像") + } + + private var infoCard: some View { + VStack(spacing: 0) { + infoRow(title: "姓名") { + plainInfoValue(viewModel.displayRealName) + } + + divider + + Button { + toastCenter.show("账号切换待接入") + } label: { + infoRow(title: "当前账号") { + VStack(alignment: .trailing, spacing: 3) { + rowValueText(currentAccountDisplayName) + Text(currentAccountTypeText) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(1) + } + rowChevron + } + } + .buttonStyle(.plain) + + divider + + infoRow(title: "手机号") { + plainInfoValue(viewModel.displayPhone) + } + + divider + + Button { + showsPasswordSheet = true + } label: { + infoRow(title: "修改密码") { + rowChevron + } + } + .buttonStyle(.plain) + + divider + + Button { + toastCenter.show("实名认证页面待接入") + } label: { + infoRow(title: "认证状态") { + realNameStatusView + rowChevron + } + } + .buttonStyle(.plain) + + divider + + infoRow(title: "账号状态") { + accountStatusView + } + + divider + + infoRow(title: "当前景区") { + scenicStatusView + } + } + .padding(.horizontal, 24) + .padding(.vertical, 4) + .background(.white, in: RoundedRectangle(cornerRadius: 17, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .stroke(Color(hex: 0xEEF3FA), lineWidth: 1) + } + .shadow(color: Color(hex: 0xC8D7EA, alpha: 0.30), radius: 16, x: 0, y: 8) + } + + private var divider: some View { + Rectangle() + .fill(Color(hex: 0xE8EDF4)) + .frame(height: 1) + } + + private var logoutButton: some View { + Button { + showsLogoutConfirm = true + } label: { + Text("退出登录") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color(hex: 0xFF3B3B)) + .frame(maxWidth: .infinity) + .frame(height: 58) + .background(.white, in: RoundedRectangle(cornerRadius: 17, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .stroke(Color(hex: 0xEEF3FA), lineWidth: 1) + } + .shadow(color: Color(hex: 0xC8D7EA, alpha: 0.24), radius: 14, x: 0, y: 7) + } + .buttonStyle(.plain) + } + + /// 构造资料信息行,统一左右布局和点击区域。 + private func infoRow(title: String, @ViewBuilder content: () -> Content) -> some View { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color(hex: 0x101827)) + .frame(width: 104, alignment: .leading) + Spacer(minLength: 4) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 69) + .contentShape(Rectangle()) + } + + @ViewBuilder + /// 根据文本是否为空占位,选择普通文本或状态徽标展示。 + private func plainInfoValue(_ text: String) -> some View { + if text == "--" { + statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6)) + } else { + rowValueText(text) + } + } + + /// 构造资料行右侧的普通值文本。 + private func rowValueText(_ text: String) -> some View { + Text(text) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(Color(hex: 0x333333)) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + + private var rowChevron: some View { + Image(systemName: "chevron.right") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(Color(hex: 0x9AA1AA)) + } + + @ViewBuilder + private var realNameStatusView: some View { + if viewModel.realNameInfo == nil { + Text(viewModel.realNameStatusText) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(Color(hex: 0xFF7B00)) + .lineLimit(1) + } else { + statusBadge( + text: viewModel.realNameStatusText, + icon: viewModel.realNameInfo?.auditStatus == 2 ? "checkmark.shield.fill" : nil, + foreground: realNameStatusColor, + background: realNameStatusBackground + ) + } + } + + private var accountStatusView: some View { + let text = viewModel.accountStatusText + return Group { + if text == "--" { + statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6)) + } else { + statusBadge(text: text, icon: "checkmark.circle.fill", foreground: Color(hex: 0x14964A), background: Color(hex: 0xDDF8E9)) + } + } + } + + private var scenicStatusView: some View { + let text = nonEmpty(accountContext.currentScenic?.name) ?? "--" + return Group { + if text == "--" { + statusBadge(text: text, foreground: Color(hex: 0x606A7A), background: Color(hex: 0xF0F2F6)) + } else { + statusBadge(text: text, icon: "mappin.circle.fill", foreground: AppDesign.primary, background: Color(hex: 0xE7F1FF)) + } + } + } + + /// 构造状态徽标,支持可选图标、前景色和背景色。 + private func statusBadge(text: String, icon: String? = nil, foreground: Color, background: Color) -> some View { + HStack(spacing: 7) { + if let icon { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + } + Text(text) + .lineLimit(1) + } + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(foreground) + .padding(.horizontal, icon == nil ? 14 : 12) + .frame(height: 34) + .background(background, in: Capsule()) + } + + private var realNameStatusColor: Color { + guard let realNameInfo = viewModel.realNameInfo else { return Color(hex: 0xFF7B00) } + switch realNameInfo.auditStatus { + case 2: + return Color(hex: 0x14964A) + case 3: + return Color(hex: 0xEF4444) + default: + return Color(hex: 0xFF7B00) + } + } + + private var realNameStatusBackground: Color { + guard let realNameInfo = viewModel.realNameInfo else { return Color(hex: 0xF4F4F4) } + switch realNameInfo.auditStatus { + case 2: + return Color(hex: 0xDDF8E9) + case 3: + return Color(hex: 0xFFE7E7) + default: + return Color(hex: 0xFFF0E2) + } + } + + private var currentAccountDisplayName: String { + nonEmpty(accountContext.currentStore?.name) + ?? nonEmpty(accountContext.currentScenic?.name) + ?? accountContext.profile?.displayName + ?? viewModel.displayNickname + } + + private var currentAccountTypeText: String { + if accountContext.currentStore != nil { + return "门店账号" + } + if accountContext.currentScenic != nil { + return "景区账号" + } + return nonEmpty(viewModel.userInfo?.roleName) ?? "账号" + } + + private var nicknameBinding: Binding { + Binding( + get: { viewModel.editingNickname }, + set: { viewModel.editingNickname = $0 } + ) + } + + /// 重新拉取个人资料,并同步更新全局账号资料。 + private func reloadProfile(showToast: Bool) async { + do { + try await viewModel.reload(api: profileAPI) + if let userInfo = viewModel.userInfo { + authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext) + } else { + accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile)) + } + if showToast { + toastCenter.show("个人信息已刷新") + } + } catch is CancellationError { + return + } catch { + toastCenter.show(error.localizedDescription) + } + } + + /// 保存昵称编辑内容,并在成功后刷新全局账号展示。 + private func saveProfileEdits() async { + do { + try await viewModel.saveProfile(api: profileAPI) + if let userInfo = viewModel.userInfo { + authSessionCoordinator.refreshCachedProfile(from: userInfo, accountContext: accountContext) + } else { + accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile)) + } + toastCenter.show("个人信息已保存") + } catch { + toastCenter.show(error.localizedDescription) + } + } + + /// 提交新密码,并在成功后关闭密码弹窗。 + private func updatePassword(_ password: String) async { + do { + try await viewModel.updatePassword(password, api: profileAPI) + showsPasswordSheet = false + toastCenter.show("密码修改成功") + } catch { + toastCenter.show(error.localizedDescription) + } + } + + /// 去除空白字符后返回非空字符串。 + private func nonEmpty(_ value: String?) -> String? { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } +} + +/// 异步头像视图,负责加载网络头像并在失败或空地址时展示占位图。 +private struct AsyncAvatarImage: View { + let urlString: String + + var body: some View { + if let url = URL(string: urlString), !urlString.isEmpty { + AsyncImage(url: url) { phase in + switch phase { + case let .success(image): + image + .resizable() + .scaledToFill() + case .failure, .empty: + placeholder + @unknown default: + placeholder + } + } + } else { + placeholder + } + } + + private var placeholder: some View { + Image(systemName: "person.fill") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppDesign.primarySoft) + } +} + +/// 个人信息头部背景波浪形状,用于还原旧版蓝白卡片视觉。 +private struct ProfileHeaderWaveShape: Shape { + var phase: CGFloat = 0 + + /// 根据当前矩形尺寸生成波浪路径。 + func path(in rect: CGRect) -> Path { + var path = Path() + let startY = rect.height * (0.94 - phase * 0.16) + path.move(to: CGPoint(x: rect.width * (0.26 + phase * 0.16), y: rect.height)) + path.addCurve( + to: CGPoint(x: rect.width, y: rect.height * (0.26 + phase * 0.08)), + control1: CGPoint(x: rect.width * (0.55 + phase * 0.06), y: startY), + control2: CGPoint(x: rect.width * (0.70 + phase * 0.08), y: rect.height * (0.40 + phase * 0.14)) + ) + path.addLine(to: CGPoint(x: rect.width, y: rect.height)) + path.closeSubpath() + return path + } +} + +/// 修改密码弹窗,负责采集新密码并提交给外部动作。 +private struct PasswordUpdateSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var password = "" + let onSubmit: (String) async -> Void + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + SecureField("请输入新密码", text: $password) + .textContentType(.newPassword) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .padding(.horizontal, 12) + .frame(height: 48) + .background(Color(hex: 0xF5F7FA), in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Color(hex: 0xE2E8F0), lineWidth: 1) + } + + Text("密码至少 6 位。") + .font(.system(size: 13)) + .foregroundStyle(AppDesign.textSecondary) + + Spacer() + } + .padding(16) + .navigationTitle("修改密码") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("完成") { + Task { await onSubmit(password) } + } + .disabled(password.trimmingCharacters(in: .whitespacesAndNewlines).count < 6) + } + } + } + .presentationDetents([.height(220)]) + } +} + +#Preview { + NavigationStack { + ProfileView() + .environment(AppSession()) + .environment(AccountContext()) + .environment(PermissionContext()) + .environment(ScenicSpotContext()) + .environment(AppRouter()) + .environment(ToastCenter()) + .environment(ProfileAPI(client: APIClient())) + .environment(AuthSessionCoordinator()) + } +} diff --git a/suixinkanTests/APIErrorTests.swift b/suixinkanTests/APIErrorTests.swift new file mode 100644 index 0000000..7dcc197 --- /dev/null +++ b/suixinkanTests/APIErrorTests.swift @@ -0,0 +1,29 @@ +// +// APIErrorTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/20. +// + +import XCTest +@testable import suixinkan + +/// 网络错误测试,覆盖登录凭证失效判断。 +final class APIErrorTests: XCTestCase { + /// 测试 HTTP 401 和 403 会被识别为登录失效。 + func testAuthenticationExpiredForUnauthorizedHTTPStatus() { + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(401, "Unauthorized"))) + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(403, "Forbidden"))) + } + + /// 测试后端业务码和中文失效文案会被识别为登录失效。 + func testAuthenticationExpiredForServerTokenMessages() { + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.serverCode(200001, "token 已过期"))) + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.serverCode(0, "登录失效,请重新登录"))) + } + + /// 测试普通网络错误不会被识别为登录失效。 + func testNetworkErrorIsNotAuthenticationExpired() { + XCTAssertFalse(APIError.isAuthenticationExpired(APIError.networkFailed("请求超时,请稍后重试"))) + } +} diff --git a/suixinkanTests/AccountContextLoaderTests.swift b/suixinkanTests/AccountContextLoaderTests.swift new file mode 100644 index 0000000..2e17a5e --- /dev/null +++ b/suixinkanTests/AccountContextLoaderTests.swift @@ -0,0 +1,137 @@ +// +// AccountContextLoaderTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 账号上下文加载器测试,覆盖权限、景区兜底和门店容错。 +final class AccountContextLoaderTests: XCTestCase { + /// 测试权限、用户资料、景区和门店成功时会写入上下文。 + func testRefreshLoadsRolePermissionScenicAndStoreContext() async throws { + let accountContext = AccountContext() + let permissionContext = PermissionContext() + let api = MockAccountContextAPI() + api.roles = [ + RolePermissionResponse( + role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]), + scenic: [ScenicInfo(id: 88, name: "东湖景区")] + ) + ] + api.scenicResponse = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 88, name: "东湖景区")]) + api.storeResponse = ListPayload(total: 1, list: [StoreItem(id: 66, scenicId: 88, name: "东湖门店")]) + + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: MockUserProfileAPI(), + accountContextAPI: api + ) + + XCTAssertEqual(accountContext.profile?.displayName, "测试用户") + XCTAssertEqual(permissionContext.currentRole?.id, 7) + XCTAssertTrue(permissionContext.canAccess("/home")) + XCTAssertEqual(accountContext.currentScenic?.id, 88) + XCTAssertEqual(accountContext.currentStore?.id, 66) + } + + /// 测试景区接口失败时会从角色权限景区兜底。 + func testRefreshFallsBackToRoleScenicsWhenScenicAPIThrows() async throws { + let accountContext = AccountContext() + let permissionContext = PermissionContext() + let api = MockAccountContextAPI() + api.roles = [ + RolePermissionResponse( + role: RoleInfo(id: 7, name: "运营"), + scenic: [ + ScenicInfo(id: 88, name: "东湖景区"), + ScenicInfo(id: 88, name: "东湖景区") + ] + ) + ] + api.scenicError = APIError.networkFailed("景区接口失败") + + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: MockUserProfileAPI(), + accountContextAPI: api + ) + + XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88]) + } + + /// 测试门店接口失败时不阻断上下文加载。 + func testRefreshAllowsStoreFailure() async throws { + let accountContext = AccountContext() + let permissionContext = PermissionContext() + let api = MockAccountContextAPI() + api.roles = [ + RolePermissionResponse( + role: RoleInfo(id: 7, name: "运营"), + scenic: [ScenicInfo(id: 88, name: "东湖景区")] + ) + ] + api.storeError = APIError.networkFailed("门店接口失败") + + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: MockUserProfileAPI(), + accountContextAPI: api + ) + + XCTAssertEqual(accountContext.currentScenic?.id, 88) + XCTAssertTrue(accountContext.storeScopes.isEmpty) + XCTAssertNil(accountContext.currentStore) + } +} + +@MainActor +/// 用户资料服务测试替身,返回固定用户资料。 +private final class MockUserProfileAPI: UserProfileServing { + /// 获取固定用户资料。 + func userInfo() async throws -> UserInfoResponse { + UserInfoResponse(nickname: "测试用户", roleName: "运营") + } +} + +@MainActor +/// 账号上下文服务测试替身,可分别控制各接口成功或失败。 +private final class MockAccountContextAPI: AccountContextServing { + var roles: [RolePermissionResponse] = [] + var scenicResponse = ScenicListAllResponse(total: 0, list: []) + var storeResponse = ListPayload(total: 0, list: [StoreItem]()) + var scenicError: Error? + var storeError: Error? + + /// 获取测试角色权限。 + func rolePermissions() async throws -> [RolePermissionResponse] { + roles + } + + /// 获取测试景区列表,或按配置抛出错误。 + func scenicListAll() async throws -> ScenicListAllResponse { + if let scenicError { + throw scenicError + } + return scenicResponse + } + + /// 获取测试门店列表,或按配置抛出错误。 + func storeAll() async throws -> ListPayload { + if let storeError { + throw storeError + } + return storeResponse + } + + /// 获取测试景点列表。 + func scenicSpotListAll(scenicId: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } +} diff --git a/suixinkanTests/AccountContextTests.swift b/suixinkanTests/AccountContextTests.swift new file mode 100644 index 0000000..cb13fec --- /dev/null +++ b/suixinkanTests/AccountContextTests.swift @@ -0,0 +1,133 @@ +// +// AccountContextTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/20. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 账号上下文测试,覆盖景区、门店和角色权限的选择恢复。 +final class AccountContextTests: XCTestCase { + /// 测试 replaceScopes 能按缓存 ID 恢复当前景区和门店。 + func testReplaceScopesRestoresCurrentSelectionsById() { + let context = AccountContext() + let scenicScopes = [ + BusinessScope(id: 1, name: "默认景区", kind: .scenic), + BusinessScope(id: 2, name: "缓存景区", kind: .scenic) + ] + let storeScopes = [ + BusinessScope(id: 10, name: "默认门店", kind: .store), + BusinessScope(id: 20, name: "缓存门店", kind: .store) + ] + + context.replaceScopes( + scenic: scenicScopes, + stores: storeScopes, + currentScenicId: 2, + currentStoreId: 20 + ) + + XCTAssertEqual(context.currentScenic?.id, 2) + XCTAssertEqual(context.currentStore?.id, 20) + } + + /// 测试缓存 ID 不存在时会回退到首个可用作用域。 + func testReplaceScopesFallsBackToFirstScopeWhenCachedIdIsMissing() { + let context = AccountContext() + + context.replaceScopes( + scenic: [BusinessScope(id: 1, name: "默认景区", kind: .scenic)], + stores: [BusinessScope(id: 10, name: "默认门店", kind: .store)], + currentScenicId: 999, + currentStoreId: 999 + ) + + XCTAssertEqual(context.currentScenic?.id, 1) + XCTAssertEqual(context.currentStore?.id, 10) + } + + /// 测试切换景区时会优先选择同景区门店。 + func testSelectScenicResolvesStoreWithinSelectedScenic() { + let context = AccountContext() + context.replaceScopes( + scenic: [ + BusinessScope(id: 1, name: "西湖景区", kind: .scenic), + BusinessScope(id: 2, name: "东湖景区", kind: .scenic) + ], + stores: [ + BusinessScope(id: 10, name: "西湖门店", kind: .store, parentScenicId: 1), + BusinessScope(id: 20, name: "东湖门店", kind: .store, parentScenicId: 2) + ], + currentScenicId: 1, + currentStoreId: 10 + ) + + context.selectScenic(id: 2) + + XCTAssertEqual(context.currentScenic?.id, 2) + XCTAssertEqual(context.currentStore?.id, 20) + } + + /// 测试切换角色时会同步角色景区并重新匹配门店。 + func testSelectRoleUpdatesScenicScopesAndStoreSelection() { + let accountContext = AccountContext() + let permissionContext = PermissionContext() + accountContext.replaceScopes( + scenic: [BusinessScope(id: 1, name: "旧景区", kind: .scenic)], + stores: [ + BusinessScope(id: 10, name: "旧门店", kind: .store, parentScenicId: 1), + BusinessScope(id: 20, name: "新门店", kind: .store, parentScenicId: 2) + ], + currentScenicId: 1, + currentStoreId: 10 + ) + permissionContext.replaceRolePermissions([ + RolePermissionResponse( + role: RoleInfo(id: 1, name: "旧角色"), + scenic: [ScenicInfo(id: 1, name: "旧景区")] + ), + RolePermissionResponse( + role: RoleInfo(id: 2, name: "新角色"), + scenic: [ScenicInfo(id: 2, name: "新景区")] + ) + ]) + + permissionContext.selectRole(id: 2, accountContext: accountContext) + + XCTAssertEqual(permissionContext.currentRole?.id, 2) + XCTAssertEqual(accountContext.scenicScopes.map(\.id), [2]) + XCTAssertEqual(accountContext.currentScenic?.id, 2) + XCTAssertEqual(accountContext.currentStore?.id, 20) + } + + /// 测试权限上下文会递归展开权限 URI。 + func testPermissionContextFlattensPermissionURIs() { + let permissionContext = PermissionContext() + permissionContext.replaceRolePermissions([ + RolePermissionResponse( + role: RoleInfo( + id: 1, + name: "运营", + permission: [ + PermissionItem( + id: 1, + name: "首页", + uri: "/home", + children: [ + PermissionItem(id: 2, name: "订单", uri: "/orders") + ] + ) + ] + ), + scenic: [] + ) + ]) + + XCTAssertTrue(permissionContext.canAccess("/home")) + XCTAssertTrue(permissionContext.canAccess("/orders")) + XCTAssertFalse(permissionContext.canAccess("/settings")) + } +} diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift new file mode 100644 index 0000000..d00ee2c --- /dev/null +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -0,0 +1,80 @@ +// +// HomeCommonMenuStoreTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +/// 首页常用应用存储测试,覆盖默认值、过滤和别名去重。 +final class HomeCommonMenuStoreTests: XCTestCase { + /// 测试首次无配置时会生成默认常用应用。 + func testLoadCreatesDefaultCommonMenusWhenBaselineIsMissing() { + let defaults = makeIsolatedDefaults() + let store = HomeCommonMenuStore(defaults: defaults) + let menus = [ + menu("registration_invitation"), + menu("location_report"), + menu("pm_manager") + ] + + let uris = store.load(menuItems: menus) + + XCTAssertEqual(uris, ["registration_invitation", "location_report", "pm_manager"]) + XCTAssertTrue(defaults.bool(forKey: HomeCommonMenuStore.defaultBaselineKey)) + } + + /// 测试已保存 URI 会按当前权限过滤并解析同义 URI。 + func testLoadFiltersSavedUrisByCurrentPermissions() { + let defaults = makeIsolatedDefaults() + defaults.set(true, forKey: HomeCommonMenuStore.defaultBaselineKey) + defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.defaultStorageKey) + let store = HomeCommonMenuStore(defaults: defaults) + let menus = [ + menu("task_management_editor"), + menu("pm_manager") + ] + + let uris = store.load(menuItems: menus) + + XCTAssertEqual(uris, ["task_management_editor", "pm_manager"]) + } + + /// 测试添加常用应用时会按同义 URI 去重。 + func testAddDeduplicatesAliases() { + let defaults = makeIsolatedDefaults() + let store = HomeCommonMenuStore(defaults: defaults) + let menus = [ + menu("task_management_editor") + ] + + let uris = store.add("task_management", current: ["task_management_editor"], menuItems: menus) + + XCTAssertEqual(uris, ["task_management_editor"]) + } + + /// 测试移除常用应用时会移除同义 URI。 + func testRemoveDeletesAliases() { + let defaults = makeIsolatedDefaults() + let store = HomeCommonMenuStore(defaults: defaults) + + let uris = store.remove("pm", current: ["pm_manager", "location_report"]) + + XCTAssertEqual(uris, ["location_report"]) + } + + /// 创建测试菜单实体。 + private func menu(_ uri: String) -> HomeMenuItem { + HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil) + } + + /// 创建独立 UserDefaults,避免测试污染真实 App 偏好。 + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "suixinkan.home.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift new file mode 100644 index 0000000..41cc361 --- /dev/null +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -0,0 +1,152 @@ +// +// HomeMenuRouterTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +/// 首页菜单路由测试,覆盖 URI 映射、别名和未知路由诊断。 +final class HomeMenuRouterTests: XCTestCase { + override func setUp() { + super.setUp() + HomeRouteDiagnostics.resetUnknownRoutes() + } + + override func tearDown() { + HomeRouteDiagnostics.resetUnknownRoutes() + super.tearDown() + } + + /// 测试已迁移的首页路由会解析到真实目标。 + func testMigratedRoutesResolveToDestinations() { + XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "store", title: ""), .destination(.moreFunctions)) + } + + /// 测试已知但未迁移的首页路由会进入安全占位。 + func testKnownUnmigratedRoutesResolveToPlaceholders() { + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "task_management", title: ""), + .destination(.modulePlaceholder(uri: "task_management", title: "任务管理")) + ) + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "payment_qr", title: ""), + .destination(.modulePlaceholder(uri: "payment_qr", title: "收款码")) + ) + } + + /// 测试 Tab 路由仍集中在 HomeMenuRouter。 + func testTabRoutesStayCentralized() { + XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .tab(.orders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .tab(.orders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .tab(.orders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics)) + } + + /// 测试飞控相关入口属于已知不支持范围。 + func testFlightControlRoutesAreKnownUnsupportedScope() { + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "fly", title: ""), + .unsupported(uri: "fly", title: "飞行管理", reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。") + ) + } + + /// 测试未知 URI 会显式进入 placeholder。 + func testUnknownRouteIsExplicitPlaceholder() { + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "android_only_feature", title: "Android 专项"), + .placeholder(uri: "android_only_feature", title: "Android 专项") + ) + } + + /// 测试 canonicalURI 会使用当前权限可用的同义 URI。 + func testCanonicalURIUsesAvailableAlias() { + let available: Set = ["task_management_editor", "photographer_invite", "pm_manager", "payment_qr"] + XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "task_management", availableURIs: available), "task_management_editor") + XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "registration_invitation", availableURIs: available), "photographer_invite") + XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm", availableURIs: available), "pm_manager") + XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "payment_code", availableURIs: available), "payment_qr") + } + + /// 测试 menuAliasKey 会把同义入口分到同一组。 + func testMenuAliasKeyGroupsDuplicateAndroidEntries() { + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "task_management_editor"), "task_management") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "photographer_invite"), "registration_invitation") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "pm_manager"), "pm") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "payment_code"), "payment_collection") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "deposit_order_shooting_info"), "deposit_order") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-order-manage"), "photographer_orders") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "android_only_module"), "android_only_module") + } + + /// 测试未知路由诊断会去重并更新标题。 + func testUnknownRouteDiagnosticsDeduplicatesAndUpdatesTitle() { + HomeRouteDiagnostics.recordUnknown(uri: "android_feature", title: "旧标题") + HomeRouteDiagnostics.recordUnknown(uri: "android_feature", title: "新标题") + HomeRouteDiagnostics.recordUnknown(uri: "another_feature", title: "") + + let records = HomeRouteDiagnostics.unknownRoutes() + + XCTAssertEqual(records.count, 2) + XCTAssertEqual(records.first { $0.uri == "android_feature" }?.title, "新标题") + XCTAssertEqual(records.first { $0.uri == "android_feature" }?.count, 2) + XCTAssertEqual(records.first { $0.uri == "another_feature" }?.count, 1) + } + + /// 测试未知路由诊断报告是 Markdown 且按出现次数排序。 + func testUnknownRouteDiagnosticsReportIsMarkdownAndSortedByCount() { + HomeRouteDiagnostics.recordUnknown(uri: "rare_feature", title: "低频入口") + HomeRouteDiagnostics.recordUnknown(uri: "hot_feature", title: "高频入口") + HomeRouteDiagnostics.recordUnknown(uri: "hot_feature", title: "高频入口") + + let report = HomeRouteDiagnostics.unknownRouteReport(generatedAt: Date(timeIntervalSince1970: 0)) + + XCTAssertTrue(report.contains("# Home Route Diagnostics")) + XCTAssertTrue(report.contains("| hot_feature | 高频入口 | 2 |")) + XCTAssertLessThan( + report.range(of: "hot_feature")!.lowerBound, + report.range(of: "rare_feature")!.lowerBound + ) + } + + /// 测试权限路由审计会区分可路由、不支持和未知入口。 + func testPermissionRouteAuditSeparatesRoutableUnsupportedAndUnknownEntries() { + let permissions = [ + rolePermission( + roleId: 1, + permissions: [ + permission(id: 1, name: "任务管理", uri: "task_management", children: [ + permission(id: 2, name: "发布任务", uri: "task_create") + ]), + permission(id: 3, name: "飞控", uri: "pilot_controller"), + permission(id: 4, name: "Android 专项入口", uri: "android_only_module"), + permission(id: 5, name: "重复任务", uri: "task_management") + ] + ) + ] + + let audit = HomePermissionRouteAuditor.audit(permissions: permissions, currentRoleId: 1) + + XCTAssertEqual(audit.routable.map(\.uri), ["task_management", "task_create"]) + XCTAssertEqual(audit.unsupported.map(\.uri), ["pilot_controller"]) + XCTAssertEqual(audit.unknown.map(\.uri), ["android_only_module"]) + XCTAssertTrue(audit.hasUnknownRoutes) + } + + /// 创建角色权限测试实体。 + private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse { + RolePermissionResponse( + role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions), + scenic: [] + ) + } + + /// 创建权限节点测试实体。 + private func permission(id: Int, name: String, uri: String, children: [PermissionItem] = []) -> PermissionItem { + PermissionItem(id: id, name: name, uri: uri, children: children) + } +} diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift new file mode 100644 index 0000000..206718f --- /dev/null +++ b/suixinkanTests/HomeViewModelTests.swift @@ -0,0 +1,111 @@ +// +// HomeViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 首页 ViewModel 测试,覆盖权限菜单构建、排序和去重。 +final class HomeViewModelTests: XCTestCase { + /// 测试当前角色缺失时清空菜单,不回退到其他角色。 + func testBuildMenusClearsStaleMenusWhenCurrentRoleIsMissing() { + let viewModel = HomeViewModel() + let permissions = [ + rolePermission(roleId: 1, permissions: [ + permission(id: 1, name: "消息中心", uri: "message_center") + ]) + ] + + viewModel.buildMenus(from: permissions, currentRoleId: 1) + XCTAssertEqual(viewModel.menuItems.map(\.uri), ["message_center"]) + + viewModel.buildMenus(from: permissions, currentRoleId: 99) + + XCTAssertTrue(viewModel.menuItems.isEmpty) + } + + /// 测试当前角色为空时才回退到第一个角色。 + func testBuildMenusFallsBackToFirstRoleOnlyWhenCurrentRoleIsNil() { + let viewModel = HomeViewModel() + let permissions = [ + rolePermission(roleId: 1, permissions: [ + permission(id: 1, name: "任务管理", uri: "task_management") + ]), + rolePermission(roleId: 2, permissions: [ + permission(id: 2, name: "消息中心", uri: "message_center") + ]) + ] + + viewModel.buildMenus(from: permissions, currentRoleId: nil) + + XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management"]) + } + + /// 测试权限树会递归展开为菜单。 + func testBuildMenusFlattensPermissionTree() { + let viewModel = HomeViewModel() + let permissions = [ + rolePermission(roleId: 1, permissions: [ + permission(id: 1, name: "任务管理", uri: "task_management", children: [ + permission(id: 2, name: "消息中心", uri: "message_center") + ]) + ]) + ] + + viewModel.buildMenus(from: permissions, currentRoleId: 1) + + XCTAssertEqual(viewModel.menuItems.map(\.uri), ["task_management", "message_center"]) + } + + /// 测试同义 URI 会去重,只保留首次出现的菜单。 + func testBuildMenusDeduplicatesKnownAndroidAliases() { + let viewModel = HomeViewModel() + let permissions = [ + rolePermission(roleId: 1, permissions: [ + permission(id: 1, name: "项目管理", uri: "pm_manager"), + permission(id: 2, name: "项目管理旧入口", uri: "pm"), + permission(id: 3, name: "收款码", uri: "payment_code"), + permission(id: 4, name: "收款码旧入口", uri: "payment_qr"), + permission(id: 5, name: "押金订单详情", uri: "deposit_order_detail"), + permission(id: 6, name: "押金订单", uri: "deposit_order") + ]) + ] + + viewModel.buildMenus(from: permissions, currentRoleId: 1) + + XCTAssertEqual(viewModel.menuItems.map(\.uri), ["deposit_order_detail", "pm_manager", "payment_code"]) + } + + /// 测试菜单按旧工程 preferred order 排序。 + func testBuildMenusSortsByPreferredOrder() { + let viewModel = HomeViewModel() + let permissions = [ + rolePermission(roleId: 1, permissions: [ + permission(id: 1, name: "消息中心", uri: "message_center"), + permission(id: 2, name: "空间设置", uri: "space_settings"), + permission(id: 3, name: "钱包", uri: "wallet") + ]) + ] + + viewModel.buildMenus(from: permissions, currentRoleId: 1) + + XCTAssertEqual(viewModel.menuItems.map(\.uri), ["space_settings", "wallet", "message_center"]) + } + + /// 创建角色权限测试实体。 + private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse { + RolePermissionResponse( + role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions), + scenic: [] + ) + } + + /// 创建权限节点测试实体。 + private func permission(id: Int, name: String, uri: String, children: [PermissionItem] = []) -> PermissionItem { + PermissionItem(id: id, name: name, uri: uri, children: children) + } +} diff --git a/suixinkanTests/LoginViewModelTests.swift b/suixinkanTests/LoginViewModelTests.swift new file mode 100644 index 0000000..cf05f87 --- /dev/null +++ b/suixinkanTests/LoginViewModelTests.swift @@ -0,0 +1,52 @@ +// +// LoginViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/20. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和偏好恢复。 +final class LoginViewModelTests: XCTestCase { + /// 测试 +86 手机号会被规范化为 11 位国内手机号。 + func testNormalizeUsernameCountryCodeRemovesChinaPrefix() { + let viewModel = LoginViewModel() + viewModel.username = "+86 186 5185 7230" + + viewModel.normalizeUsernameCountryCodeIfNeeded() + + XCTAssertEqual(viewModel.username, "18651857230") + XCTAssertEqual(viewModel.normalizedUsername, "18651857230") + XCTAssertTrue(viewModel.isValidPhone) + } + + /// 测试未勾选协议时登录校验返回隐私协议错误。 + func testValidateForLoginRequiresPrivacyAgreement() { + let viewModel = LoginViewModel() + viewModel.username = "18651857230" + viewModel.password = "secret123" + viewModel.privacyChecked = false + + XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked) + } + + /// 测试登录偏好只恢复手机号和协议状态,不恢复密码。 + func testApplyPreferencesRestoresUsernameAndPrivacyOnly() { + let viewModel = LoginViewModel() + viewModel.password = "manual-password" + + viewModel.applyPreferences( + LoginPreferences( + lastUsername: "18651857230", + privacyAgreementAccepted: true + ) + ) + + XCTAssertEqual(viewModel.username, "18651857230") + XCTAssertTrue(viewModel.privacyChecked) + XCTAssertEqual(viewModel.password, "manual-password") + } +} diff --git a/suixinkanTests/NavigationRouterTests.swift b/suixinkanTests/NavigationRouterTests.swift new file mode 100644 index 0000000..6729b6a --- /dev/null +++ b/suixinkanTests/NavigationRouterTests.swift @@ -0,0 +1,18 @@ +// +// NavigationRouterTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +/// 主导航路由测试,覆盖 AppRoute 的跨页面导航策略。 +final class NavigationRouterTests: XCTestCase { + /// 测试通过 NavigationStack push 的子页面默认隐藏底部 TabBar。 + func testPushedRoutesHideTabBarByDefault() { + XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed) + } +} diff --git a/suixinkanTests/ScenicSpotContextTests.swift b/suixinkanTests/ScenicSpotContextTests.swift new file mode 100644 index 0000000..b05e670 --- /dev/null +++ b/suixinkanTests/ScenicSpotContextTests.swift @@ -0,0 +1,76 @@ +// +// ScenicSpotContextTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 景点上下文测试,覆盖按景区懒加载和失败隔离。 +final class ScenicSpotContextTests: XCTestCase { + /// 测试景点列表会按当前景区 ID 请求并写入上下文。 + func testReloadRequestsSpotsForScenicId() async { + let context = ScenicSpotContext() + let api = MockScenicSpotAPI() + api.spotResponse = ListPayload(total: 1, list: [ScenicSpotItem(id: 9, name: "观景台")]) + + await context.reload(scenicId: 88, api: api) + + XCTAssertEqual(api.requestedScenicIds, [88]) + XCTAssertEqual(context.scenicId, 88) + XCTAssertEqual(context.spots.map(\.id), [9]) + XCTAssertEqual(context.loadState, .loaded) + } + + /// 测试景点接口失败时只标记景点模块失败。 + func testReloadFailureOnlyAffectsScenicSpotContext() async { + let context = ScenicSpotContext() + let api = MockScenicSpotAPI() + api.error = APIError.networkFailed("景点接口失败") + + await context.reload(scenicId: 88, api: api) + + XCTAssertEqual(api.requestedScenicIds, [88]) + XCTAssertTrue(context.spots.isEmpty) + if case .failed = context.loadState { + XCTAssertTrue(true) + } else { + XCTFail("景点接口失败时应进入 failed 状态") + } + } +} + +@MainActor +/// 景点服务测试替身,记录请求过的景区 ID。 +private final class MockScenicSpotAPI: AccountContextServing { + var requestedScenicIds: [Int] = [] + var spotResponse = ListPayload(total: 0, list: [ScenicSpotItem]()) + var error: Error? + + /// 获取空角色权限,当前测试不使用。 + func rolePermissions() async throws -> [RolePermissionResponse] { + [] + } + + /// 获取空景区列表,当前测试不使用。 + func scenicListAll() async throws -> ScenicListAllResponse { + ScenicListAllResponse(total: 0, list: []) + } + + /// 获取空门店列表,当前测试不使用。 + func storeAll() async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 获取测试景点列表,并记录景区 ID。 + func scenicSpotListAll(scenicId: Int) async throws -> ListPayload { + requestedScenicIds.append(scenicId) + if let error { + throw error + } + return spotResponse + } +} diff --git a/suixinkanTests/StorageTests.swift b/suixinkanTests/StorageTests.swift new file mode 100644 index 0000000..0dd0b11 --- /dev/null +++ b/suixinkanTests/StorageTests.swift @@ -0,0 +1,60 @@ +// +// StorageTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/20. +// + +import XCTest +@testable import suixinkan + +/// 本地缓存 Store 测试,覆盖 UserDefaults 中的偏好和账号快照。 +final class StorageTests: XCTestCase { + /// 测试 AppPreferencesStore 能保存并读取上次登录手机号和协议状态。 + func testAppPreferencesStorePersistsLoginPreferences() { + let defaults = makeIsolatedDefaults() + let store = AppPreferencesStore(defaults: defaults) + + store.saveLastLoginUsername(" 18651857230 ") + store.savePrivacyAgreementAccepted(true) + + XCTAssertEqual(store.loadLastLoginUsername(), "18651857230") + XCTAssertTrue(store.loadPrivacyAgreementAccepted()) + } + + /// 测试账号快照能保存并恢复账号资料、业务作用域和当前选择。 + func testAccountSnapshotStorePersistsAccountContextSnapshot() { + let defaults = makeIsolatedDefaults() + let store = AccountSnapshotStore(defaults: defaults) + let snapshot = AccountSnapshot( + profile: AccountProfile( + userId: "101", + displayName: "测试账号", + phone: "18651857230", + avatarURL: "https://cdn.example.com/avatar.jpg" + ), + accountType: "store_user", + businessUserId: 101, + scenicScopes: [ + BusinessScope(id: 88, name: "东门景区", kind: .scenic) + ], + storeScopes: [ + BusinessScope(id: 66, name: "东门门店", kind: .store) + ], + currentScenicId: 88, + currentStoreId: 66 + ) + + store.save(snapshot) + + XCTAssertEqual(store.load(), snapshot) + } + + /// 创建独立 UserDefaults,避免测试污染真实 App 偏好。 + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "suixinkan.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +}