commit 0a0d4fbd79765c52052e4082139fac5b48085766 Author: 汉秋 <497055328@qq.com> Date: Mon Jun 22 11:28:01 2026 +0800 初始提交 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..004e2ec Binary files /dev/null and b/.DS_Store differ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..de75865 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +- AGENTS + + ## 项目 + + + iOS + - iOS App + - SwiftUI + - 最低支持 iOS 17 + - 只支持竖屏 + + + --- + + ## 架构 + + MVVM + + + --- + + ## 模块文档 + + 每个功能模块下,都需要新建一个 Markdown 文档,用来描述该模块下的业务代码逻辑 + - 文档文件建议命名为 `README.md` 或 `<模块名>.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 0000000..8310bd7 Binary files /dev/null and b/suixinkan.xcodeproj/project.xcworkspace/xcuserdata/hanqiu.xcuserdatad/UserInterfaceState.xcuserstate differ 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 0000000..1def1ed Binary files /dev/null and b/suixinkan/Assets.xcassets/LoginBackground.imageset/img_login_bg.png differ 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 0000000..679c06e Binary files /dev/null and b/suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/icon_cb_privacy_selected.png differ diff --git a/suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/Contents.json b/suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/Contents.json new file mode 100644 index 0000000..3be72a5 --- /dev/null +++ b/suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon_cb_privacy_unselect.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/icon_cb_privacy_unselect.png b/suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/icon_cb_privacy_unselect.png new file mode 100644 index 0000000..a3f2ec6 Binary files /dev/null and b/suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/icon_cb_privacy_unselect.png differ diff --git a/suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/Contents.json b/suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/Contents.json new file mode 100644 index 0000000..377c91f --- /dev/null +++ b/suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icon_pwd_invisible.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/icon_pwd_invisible.png b/suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/icon_pwd_invisible.png new file mode 100644 index 0000000..81be7df Binary files /dev/null and b/suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/icon_pwd_invisible.png differ 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 0000000..8205542 Binary files /dev/null and b/suixinkan/Assets.xcassets/LoginPwdVisible.imageset/icon_pwd_visible.png differ 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 + } +}