Initial commit
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
81
AGENTS.md
Normal file
81
AGENTS.md
Normal file
@ -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
|
||||||
|
|
||||||
|
生成的代码添加必要注释
|
||||||
|
- 复杂逻辑、关键业务规则、容易误解的地方需要注释
|
||||||
|
- 方法名需要添加注释,说明方法的作用
|
||||||
|
- 类名、结构体名需要添加注释,说明代表什么实体、承担什么职责
|
||||||
|
- 简单赋值、普通布局、显而易见的代码不需要注释
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
520
suixinkan.xcodeproj/project.pbxproj
Normal file
520
suixinkan.xcodeproj/project.pbxproj
Normal file
@ -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 = "<group>";
|
||||||
|
};
|
||||||
|
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = suixinkanTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
939AC7972FE3F832004B22E4 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
939AC7962FE3F832004B22E4 /* suixinkan.app */,
|
||||||
|
A00000012FE9000000000001 /* suixinkanTests.xctest */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
7
suixinkan.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
suixinkan.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@ -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
|
||||||
|
}
|
||||||
BIN
suixinkan.xcodeproj/project.xcworkspace/xcuserdata/hanqiu.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
BIN
suixinkan.xcodeproj/project.xcworkspace/xcuserdata/hanqiu.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
Binary file not shown.
114
suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme
Normal file
114
suixinkan.xcodeproj/xcshareddata/xcschemes/suixinkan.xcscheme
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2650"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "939AC7952FE3F832004B22E4"
|
||||||
|
BuildableName = "suixinkan.app"
|
||||||
|
BlueprintName = "suixinkan"
|
||||||
|
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "NO"
|
||||||
|
buildForProfiling = "NO"
|
||||||
|
buildForArchiving = "NO"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A00000032FE9000000000003"
|
||||||
|
BuildableName = "suixinkanTests.xctest"
|
||||||
|
BlueprintName = "suixinkanTests"
|
||||||
|
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "939AC7952FE3F832004B22E4"
|
||||||
|
BuildableName = "suixinkan.app"
|
||||||
|
BlueprintName = "suixinkan"
|
||||||
|
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A00000032FE9000000000003"
|
||||||
|
BuildableName = "suixinkanTests.xctest"
|
||||||
|
BlueprintName = "suixinkanTests"
|
||||||
|
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "939AC7952FE3F832004B22E4"
|
||||||
|
BuildableName = "suixinkan.app"
|
||||||
|
BlueprintName = "suixinkan"
|
||||||
|
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "939AC7952FE3F832004B22E4"
|
||||||
|
BuildableName = "suixinkan.app"
|
||||||
|
BlueprintName = "suixinkan"
|
||||||
|
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>suixinkan.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
64
suixinkan/App/App.md
Normal file
64
suixinkan/App/App.md
Normal file
@ -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` 处理跳转。
|
||||||
63
suixinkan/App/Navigation/AppTab.swift
Normal file
63
suixinkan/App/Navigation/AppTab.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
suixinkan/App/Navigation/NavigationRouter.swift
Normal file
88
suixinkan/App/Navigation/NavigationRouter.swift
Normal file
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
125
suixinkan/App/RootView.swift
Normal file
125
suixinkan/App/RootView.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
129
suixinkan/App/State/AccountContext.swift
Normal file
129
suixinkan/App/State/AccountContext.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
115
suixinkan/App/State/AccountContextLoader.swift
Normal file
115
suixinkan/App/State/AccountContextLoader.swift
Normal file
@ -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<StoreItem>? {
|
||||||
|
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<Int>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
46
suixinkan/App/State/AppSession.swift
Normal file
46
suixinkan/App/State/AppSession.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
211
suixinkan/App/State/AuthSessionCoordinator.swift
Normal file
211
suixinkan/App/State/AuthSessionCoordinator.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
83
suixinkan/App/State/PermissionContext.swift
Normal file
83
suixinkan/App/State/PermissionContext.swift
Normal file
@ -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<String> = []
|
||||||
|
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<Int>()
|
||||||
|
return scenics.compactMap { scenic in
|
||||||
|
guard seen.insert(scenic.id).inserted else { return nil }
|
||||||
|
return BusinessScope(id: scenic.id, name: scenic.name, kind: .scenic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
suixinkan/App/State/ScenicSpotContext.swift
Normal file
60
suixinkan/App/State/ScenicSpotContext.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
113
suixinkan/App/State/SessionBootstrapper.swift
Normal file
113
suixinkan/App/State/SessionBootstrapper.swift
Normal file
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
69
suixinkan/App/State/ToastCenter.swift
Normal file
69
suixinkan/App/State/ToastCenter.swift
Normal file
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
18
suixinkan/App/suixinkanApp.swift
Normal file
18
suixinkan/App/suixinkanApp.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
suixinkan/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
suixinkan/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
35
suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
35
suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
suixinkan/Assets.xcassets/Contents.json
Normal file
6
suixinkan/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
21
suixinkan/Assets.xcassets/LoginBackground.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/LoginBackground.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/LoginBackground.imageset/img_login_bg.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/LoginBackground.imageset/img_login_bg.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
21
suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/icon_cb_privacy_selected.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/LoginCheckboxChecked.imageset/icon_cb_privacy_selected.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
21
suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/icon_cb_privacy_unselect.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/LoginCheckboxUnchecked.imageset/icon_cb_privacy_unselect.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
21
suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/icon_pwd_invisible.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/LoginPwdInvisible.imageset/icon_pwd_invisible.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
21
suixinkan/Assets.xcassets/LoginPwdVisible.imageset/Contents.json
vendored
Normal file
21
suixinkan/Assets.xcassets/LoginPwdVisible.imageset/Contents.json
vendored
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
suixinkan/Assets.xcassets/LoginPwdVisible.imageset/icon_pwd_visible.png
vendored
Normal file
BIN
suixinkan/Assets.xcassets/LoginPwdVisible.imageset/icon_pwd_visible.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
65
suixinkan/Core/Core.md
Normal file
65
suixinkan/Core/Core.md
Normal file
@ -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<Response>` 解包:
|
||||||
|
- `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`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。
|
||||||
29
suixinkan/Core/Design/AppDesign.swift
Normal file
29
suixinkan/Core/Design/AppDesign.swift
Normal file
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
suixinkan/Core/Design/AppMetrics.swift
Normal file
67
suixinkan/Core/Design/AppMetrics.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
257
suixinkan/Core/Networking/APIClient.swift
Normal file
257
suixinkan/Core/Networking/APIClient.swift
Normal file
@ -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<Response: Decodable>(
|
||||||
|
_ apiRequest: APIRequest<Response>,
|
||||||
|
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<Response: Decodable>(
|
||||||
|
_ apiRequest: APIRequest<Response>,
|
||||||
|
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<Response: Decodable>(_ responseType: Response.Type, from data: Data) throws -> Response {
|
||||||
|
let envelope: APIEnvelope<Response>
|
||||||
|
do {
|
||||||
|
envelope = try decoder.decode(APIEnvelope<Response>.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 ?? "<invalid url>"
|
||||||
|
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 ?? "<invalid url>"
|
||||||
|
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 ?? "<invalid url>"
|
||||||
|
print("[API][Cancelled] \(method) \(url) reason=\(reason)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// 将响应体格式化为便于调试阅读的字符串。
|
||||||
|
private static func debugResponseBody(from data: Data) -> String {
|
||||||
|
guard !data.isEmpty else { return "<empty response>" }
|
||||||
|
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) ?? "<non-utf8 response: \(data.count) bytes>"
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var nonEmpty: String? {
|
||||||
|
isEmpty ? nil : self
|
||||||
|
}
|
||||||
|
}
|
||||||
62
suixinkan/Core/Networking/APIEnvelope.swift
Normal file
62
suixinkan/Core/Networking/APIEnvelope.swift
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
//
|
||||||
|
// APIEnvelope.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/18.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 后端统一响应包裹实体,承载业务 code、msg 和真实 data。
|
||||||
|
struct APIEnvelope<T: Decodable>: 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?
|
||||||
|
}
|
||||||
62
suixinkan/Core/Networking/APIEnvironment.swift
Normal file
62
suixinkan/Core/Networking/APIEnvironment.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
61
suixinkan/Core/Networking/APIError.swift
Normal file
61
suixinkan/Core/Networking/APIError.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
suixinkan/Core/Networking/APIRequest.swift
Normal file
55
suixinkan/Core/Networking/APIRequest.swift
Normal file
@ -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<Response: Decodable> {
|
||||||
|
var method: HTTPMethod
|
||||||
|
var path: String
|
||||||
|
var queryItems: [URLQueryItem]
|
||||||
|
var headers: [String: String]
|
||||||
|
var body: AnyEncodable?
|
||||||
|
|
||||||
|
/// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。
|
||||||
|
init<Body: Encodable>(
|
||||||
|
method: HTTPMethod,
|
||||||
|
path: String,
|
||||||
|
queryItems: [URLQueryItem] = [],
|
||||||
|
headers: [String: String] = [:],
|
||||||
|
body: Body? = Optional<EmptyPayload>.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: Encodable>(_ value: Value) {
|
||||||
|
encodeValue = value.encode(to:)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将被包装的请求体编码到目标 Encoder。
|
||||||
|
nonisolated func encode(to encoder: Encoder) throws {
|
||||||
|
try encodeValue(encoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
suixinkan/Core/Networking/ListPayload.swift
Normal file
55
suixinkan/Core/Networking/ListPayload.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// ListPayload.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
// Created by Codex on 2026/6/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 列表响应实体,表示后端常见的 total + list 分页或全量列表结构。
|
||||||
|
struct ListPayload<T: Decodable>: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
305
suixinkan/Core/Networking/Networking.md
Normal file
305
suixinkan/Core/Networking/Networking.md
Normal file
@ -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<Response>`。
|
||||||
|
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<Response>` 是业务接口和网络底层之间的桥梁。
|
||||||
|
|
||||||
|
它包含:
|
||||||
|
- `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>
|
||||||
|
```
|
||||||
|
|
||||||
|
空 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<T>` 表示:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct APIEnvelope<T: Decodable>: 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<Response>` 解码时抛出:
|
||||||
|
|
||||||
|
```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 下默认测试环境,真机调试时需要注意接口环境。
|
||||||
86
suixinkan/Core/Storage/AccountSnapshotStore.swift
Normal file
86
suixinkan/Core/Storage/AccountSnapshotStore.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
suixinkan/Core/Storage/AppPreferencesStore.swift
Normal file
46
suixinkan/Core/Storage/AppPreferencesStore.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
suixinkan/Core/Storage/SessionTokenStore.swift
Normal file
102
suixinkan/Core/Storage/SessionTokenStore.swift
Normal file
@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
78
suixinkan/Features/Account/API/AccountContextAPI.swift
Normal file
78
suixinkan/Features/Account/API/AccountContextAPI.swift
Normal file
@ -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<StoreItem>
|
||||||
|
|
||||||
|
/// 获取指定景区下的景点或打卡点列表。
|
||||||
|
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<StoreItem> {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/app/store/all"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定景区下的景点或打卡点列表。
|
||||||
|
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
|
||||||
|
queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
suixinkan/Features/Account/Account.md
Normal file
36
suixinkan/Features/Account/Account.md
Normal file
@ -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 做隔离。
|
||||||
355
suixinkan/Features/Account/Models/AccountContextModels.swift
Normal file
355
suixinkan/Features/Account/Models/AccountContextModels.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
44
suixinkan/Features/Auth/API/AuthAPI.swift
Normal file
44
suixinkan/Features/Auth/API/AuthAPI.swift
Normal file
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
suixinkan/Features/Auth/Auth.md
Normal file
63
suixinkan/Features/Auth/Auth.md
Normal file
@ -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`,不落盘。
|
||||||
409
suixinkan/Features/Auth/Models/AuthModels.swift
Normal file
409
suixinkan/Features/Auth/Models/AuthModels.swift
Normal file
@ -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<Int>()
|
||||||
|
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<Int>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
213
suixinkan/Features/Auth/ViewModels/LoginViewModel.swift
Normal file
213
suixinkan/Features/Auth/ViewModels/LoginViewModel.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
216
suixinkan/Features/Auth/Views/AccountSelectionView.swift
Normal file
216
suixinkan/Features/Auth/Views/AccountSelectionView.swift
Normal file
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
482
suixinkan/Features/Auth/Views/LoginView.swift
Normal file
482
suixinkan/Features/Auth/Views/LoginView.swift
Normal file
@ -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<String>, 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<String> {
|
||||||
|
Binding(
|
||||||
|
get: { viewModel.username },
|
||||||
|
set: { viewModel.username = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var passwordBinding: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { viewModel.password },
|
||||||
|
set: { viewModel.password = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showsAgreementSheetBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { viewModel.showsAgreementSheet },
|
||||||
|
set: { viewModel.showsAgreementSheet = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pendingAccountSelectionBinding: Binding<AccountSelectionPayload?> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
36
suixinkan/Features/Home/Home.md
Normal file
36
suixinkan/Features/Home/Home.md
Normal file
@ -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 的映射,并同步补充单元测试。
|
||||||
62
suixinkan/Features/Home/Models/HomeMenuItem.swift
Normal file
62
suixinkan/Features/Home/Models/HomeMenuItem.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
412
suixinkan/Features/Home/Routing/HomeMenuRouter.swift
Normal file
412
suixinkan/Features/Home/Routing/HomeMenuRouter.swift
Normal file
@ -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>) -> 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<String>()
|
||||||
|
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: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
91
suixinkan/Features/Home/Services/HomeCommonMenuStore.swift
Normal file
91
suixinkan/Features/Home/Services/HomeCommonMenuStore.swift
Normal file
@ -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>) -> [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<String>()
|
||||||
|
return uris.filter { uri in
|
||||||
|
seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
suixinkan/Features/Home/ViewModels/HomeViewModel.swift
Normal file
116
suixinkan/Features/Home/ViewModels/HomeViewModel.swift
Normal file
@ -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<String>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
suixinkan/Features/Home/Views/HomeIconCatalog.swift
Normal file
87
suixinkan/Features/Home/Views/HomeIconCatalog.swift
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
213
suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift
Normal file
213
suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
suixinkan/Features/Home/Views/HomeSupportViews.swift
Normal file
82
suixinkan/Features/Home/Views/HomeSupportViews.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
443
suixinkan/Features/Home/Views/HomeView.swift
Normal file
443
suixinkan/Features/Home/Views/HomeView.swift
Normal file
@ -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<Int> = [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())
|
||||||
|
}
|
||||||
|
}
|
||||||
40
suixinkan/Features/Main/Main.md
Normal file
40
suixinkan/Features/Main/Main.md
Normal file
@ -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` 策略中单独放开。
|
||||||
|
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||||
38
suixinkan/Features/Main/Views/MainTabsView.swift
Normal file
38
suixinkan/Features/Main/Views/MainTabsView.swift
Normal file
@ -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())
|
||||||
|
}
|
||||||
77
suixinkan/Features/Main/Views/PlaceholderRootViews.swift
Normal file
77
suixinkan/Features/Main/Views/PlaceholderRootViews.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
suixinkan/Features/Profile/API/ProfileAPI.swift
Normal file
66
suixinkan/Features/Profile/API/ProfileAPI.swift
Normal file
@ -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 {}
|
||||||
142
suixinkan/Features/Profile/Models/ProfileModels.swift
Normal file
142
suixinkan/Features/Profile/Models/ProfileModels.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
75
suixinkan/Features/Profile/Profile.md
Normal file
75
suixinkan/Features/Profile/Profile.md
Normal file
@ -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`:审核不通过。
|
||||||
|
- 其他状态:审核中。
|
||||||
|
- 无实名认证信息时:点击去实名认证。
|
||||||
|
|
||||||
157
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
157
suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift
Normal file
@ -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 位"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
636
suixinkan/Features/Profile/Views/ProfileView.swift
Normal file
636
suixinkan/Features/Profile/Views/ProfileView.swift
Normal file
@ -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<Content: View>(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<String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
29
suixinkanTests/APIErrorTests.swift
Normal file
29
suixinkanTests/APIErrorTests.swift
Normal file
@ -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("请求超时,请稍后重试")))
|
||||||
|
}
|
||||||
|
}
|
||||||
137
suixinkanTests/AccountContextLoaderTests.swift
Normal file
137
suixinkanTests/AccountContextLoaderTests.swift
Normal file
@ -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<StoreItem> {
|
||||||
|
if let storeError {
|
||||||
|
throw storeError
|
||||||
|
}
|
||||||
|
return storeResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取测试景点列表。
|
||||||
|
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
133
suixinkanTests/AccountContextTests.swift
Normal file
133
suixinkanTests/AccountContextTests.swift
Normal file
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
80
suixinkanTests/HomeCommonMenuStoreTests.swift
Normal file
80
suixinkanTests/HomeCommonMenuStoreTests.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
152
suixinkanTests/HomeMenuRouterTests.swift
Normal file
152
suixinkanTests/HomeMenuRouterTests.swift
Normal file
@ -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<String> = ["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)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
suixinkanTests/HomeViewModelTests.swift
Normal file
111
suixinkanTests/HomeViewModelTests.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
suixinkanTests/LoginViewModelTests.swift
Normal file
52
suixinkanTests/LoginViewModelTests.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
18
suixinkanTests/NavigationRouterTests.swift
Normal file
18
suixinkanTests/NavigationRouterTests.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
suixinkanTests/ScenicSpotContextTests.swift
Normal file
76
suixinkanTests/ScenicSpotContextTests.swift
Normal file
@ -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<StoreItem> {
|
||||||
|
ListPayload(total: 0, list: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取测试景点列表,并记录景区 ID。
|
||||||
|
func scenicSpotListAll(scenicId: Int) async throws -> ListPayload<ScenicSpotItem> {
|
||||||
|
requestedScenicIds.append(scenicId)
|
||||||
|
if let error {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return spotResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
60
suixinkanTests/StorageTests.swift
Normal file
60
suixinkanTests/StorageTests.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user