commit 9edf9934325679a8ac5a5846735fde05694962ad Author: 汉秋 <497055328@qq.com> Date: Fri Jun 26 14:33:31 2026 +0800 Initial commit: suixinkan_ios UIKit rewrite project. Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f8f7fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Xcode +DerivedData/ +*.xcuserstate +xcuserdata/ + +# CocoaPods +Pods/ + +# Swift Package Manager +.build/ +Package.resolved + +# macOS +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..525aa3b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,121 @@ +# Agent Instructions + +本文件用于向 AI Agent 提供项目级指导。Cursor 会在处理本项目时自动读取此文件。 + +> 如需按文件类型或目录生效的规则,可使用 `.cursor/rules/*.mdc`(支持 globs 与条件触发)。 + +--- + +## 项目概述 + +这是一个 iOS 项目,最低支持 **iOS 16**,使用 **Swift + UIKit** 开发。 + +**工程目的:** 将同目录下的 [`suixinkan_ios_new`](../suixinkan_ios_new)(SwiftUI 工程)的功能,使用 Swift + UIKit 重写一遍。 + +- 当前工程:`suixinkan_ios_new_uikit` +- 参考工程:`../suixinkan_ios_new`(SwiftUI) +- 平台:iOS 16+ +- 语言:Swift +- UI 框架:UIKit + +--- + +## 重写原则 + +- 功能、业务流程、API、数据模型以参考工程 `suixinkan_ios_new` 为准 +- UI 使用 UIKit 实现,交互与表现应与参考工程保持一致 +- 实现时可参考参考工程的模块划分与命名,但视图层须用 UIKit 重写,不使用 SwiftUI + +--- + +## 架构 + +采用 **MVVM** 设计模式: + +- **View**:UIView / UIViewController,负责 UI 展示与用户交互 +- **ViewModel**:处理业务逻辑与状态,向 View 提供数据与命令 +- **Model**:数据模型与网络/持久化层 + +View 与 ViewModel 之间通过命令式方式绑定(如 delegate、closure、KVO 或直接属性赋值),不使用 Combine。 + +--- + +## 编码规范 + +- 使用**命令式编码**风格,逻辑清晰、步骤明确,避免过度函数式或声明式写法 +- 异步逻辑优先使用 `async/await` +- View 层只负责展示与事件转发,业务逻辑放在 ViewModel +- ViewModel 不直接持有 UIView / UIViewController 引用 + +### 注释 + +- 定义的**类**、**结构体**、**方法**均须添加注释,说明其职责、用途或行为 +- 方法内涉及较复杂的业务逻辑、分支判断、状态流转或非直观实现时,须补充行内或块注释,便于后续维护 +- 注释应简洁准确,说明「为什么」与业务含义;避免重复代码字面含义的无意义注释 + +--- + +## 列表视图 + +- **复杂列表**(多 section、多布局、网格/混排、频繁局部刷新等)尽量使用 **UICollectionView** +- **简单列表**(单列、结构固定、交互简单)可以使用 **UITableView** +- UICollectionView 的数据驱动与刷新统一使用 **Diffable Data Source**(`UICollectionViewDiffableDataSource` + `NSDiffableDataSourceSnapshot`) +- 数据变更时通过 snapshot diff 应用更新,保留插入、删除、移动等**动画效果**;避免 `reloadData()` 全量刷新 +- Item / Section 需遵循 `Hashable`,保证 diff 计算正确 + +--- + +## 第三方库 + +**SnapKit** +- 用于 Auto Layout 约束布局 +- UI 布局优先使用 SnapKit,避免手写 NSLayoutConstraint + +**Kingfisher** +- 用于加载与展示网络图片 +- 网络图片统一通过 Kingfisher 处理,避免自行实现图片下载与缓存 + +--- + +## 模块文档 + +每个功能目录下需创建一个 Markdown 业务说明文档,用于描述该模块的业务与代码逻辑。 + +- 文档建议命名为 `README.md` 或 `<模块名>.md` +- 文档需说明:模块职责、核心业务流程、主要页面 / ViewController / ViewModel / API / Model 的关系 +- 新增或修改模块业务逻辑时,需同步更新对应文档 +- 文档只描述业务逻辑,避免记录临时实现细节或无关调试信息 + +--- + +## 测试 + +添加功能或修改功能后,需同步补充或更新单元测试。 + +- 测试用例需覆盖核心成功路径、失败路径和关键边界条件 +- 修改现有业务逻辑时,需同步调整相关测试 +- 完成改动后需运行测试,**全部通过**后方可视为完成 +- 测试不通过时,需修复代码并重新运行测试,直到全部通过 + +--- + +## 禁止事项 + +- 不要使用 SwiftUI(本工程为 UIKit 重写) +- 不要使用 **Combine** 库(包括 `@Published`、`PassthroughSubject`、`ObservableObject` 等) +- 不要修改参考工程 `suixinkan_ios_new` 的代码,除非明确要求 + +--- + +## 工作流程 + +- 新增或修改功能 → 更新模块文档 → 补充单元测试 → 运行测试直至全部通过 + +--- + +## 其他说明 + +- 同步进度详见 [功能同步Checklist.md](功能同步Checklist.md) +- 参考工程:`../suixinkan_ios_new`(SwiftUI) +- 构建:`xcodebuild -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios build` +- 测试:`xcodebuild test -workspace suixinkan_ios.xcworkspace -scheme suixinkan_ios -destination 'platform=iOS Simulator,name=iPhone 17'` diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..1ac2c29 --- /dev/null +++ b/Podfile @@ -0,0 +1,20 @@ +platform :ios, '16.0' + +target 'suixinkan_ios' do + use_frameworks! + + pod 'SnapKit', '~> 5.7' + pod 'Kingfisher', '~> 8.0' + + target 'suixinkan_iosTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' + end + end +end diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 0000000..58a8163 --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,20 @@ +PODS: + - Kingfisher (8.10.0) + - SnapKit (5.7.1) + +DEPENDENCIES: + - Kingfisher (~> 8.0) + - SnapKit (~> 5.7) + +SPEC REPOS: + trunk: + - Kingfisher + - SnapKit + +SPEC CHECKSUMS: + Kingfisher: db468f911dd666c9134dcbeec4db8bed52e4132d + SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a + +PODFILE CHECKSUM: dda5ec1556392b8b6a9747637c579bd75423177c + +COCOAPODS: 1.16.2 diff --git a/Scripts/convert_observable.py b/Scripts/convert_observable.py new file mode 100644 index 0000000..9d1d0e1 --- /dev/null +++ b/Scripts/convert_observable.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Convert ObservableObject/@Published Swift files to imperative onChange pattern.""" +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "suixinkan_ios" + +def convert_file(path: Path) -> bool: + text = path.read_text(encoding="utf-8") + original = text + + if "ObservableObject" not in text and "@Published" not in text and "import Combine" not in text: + return False + + text = re.sub(r"^import Combine\n", "", text, flags=re.MULTILINE) + + if ": ObservableObject" in text: + text = text.replace(": ObservableObject", "") + # Insert onChange after class opening brace + def add_onchange(match): + decl = match.group(0) + if "onChange:" in text[match.end():match.end()+200]: + return decl + return decl + "\n var onChange: (() -> Void)?\n" + text = re.sub( + r"(final class \w+[^{]*\{)\n", + add_onchange, + text, + count=1, + ) + + def repl_published(m): + access = m.group(1) or "" + name = m.group(2) + rest = m.group(3) + return f"{access}var {name}{rest} {{ didSet {{ onChange?() }} }}\n" + + text = re.sub( + r"@Published\s+(private\(set\)\s+)?var\s+(\w+)([^=\n]*=\s*[^\n]+)\n", + repl_published, + text, + ) + text = re.sub( + r"@Published\s+(private\(set\)\s+)?var\s+(\w+)([^=\n]*)\n", + lambda m: f"{m.group(1) or ''}var {m.group(2)}{m.group(3)} {{ didSet {{ onChange?() }} }}\n", + text, + ) + + if text != original: + path.write_text(text, encoding="utf-8") + return True + return False + +def main(): + changed = 0 + for path in ROOT.rglob("*.swift"): + if convert_file(path): + changed += 1 + print(f"converted: {path.relative_to(ROOT.parent)}") + print(f"Done. {changed} files converted.") + +if __name__ == "__main__": + main() diff --git a/suixinkan_ios.xcodeproj/project.pbxproj b/suixinkan_ios.xcodeproj/project.pbxproj new file mode 100644 index 0000000..ebbea9d --- /dev/null +++ b/suixinkan_ios.xcodeproj/project.pbxproj @@ -0,0 +1,662 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 93CC3ECB2FEE4EB300EC7B8E /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */; }; + 93CC3ECC2FEE4EB300EC7B8E /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED102FE9100000B9E2B1 /* Lottie */; }; + A1PODS012FEE49BE00240726 /* Pods_suixinkan_ios.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1PODS022FEE49BE00240726 /* Pods_suixinkan_ios.framework */; }; + A1PODS032FEE49BE00240726 /* Pods_suixinkan_iosTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1PODS042FEE49BE00240726 /* Pods_suixinkan_iosTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + A1TEST072FEE49BE00240726 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 933171462FEE49BE00240726 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9331714D2FEE49BE00240726; + remoteInfo = suixinkan_ios; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 9331714E2FEE49BE00240726 /* suixinkan_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan_ios.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A1PODS022FEE49BE00240726 /* Pods_suixinkan_ios.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkan_ios.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A1PODS042FEE49BE00240726 /* Pods_suixinkan_iosTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkan_iosTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A1PODS052FEE49BE00240726 /* Pods-suixinkan_ios.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan_ios.debug.xcconfig"; path = "Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios.debug.xcconfig"; sourceTree = ""; }; + A1PODS062FEE49BE00240726 /* Pods-suixinkan_ios.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan_ios.release.xcconfig"; path = "Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios.release.xcconfig"; sourceTree = ""; }; + A1PODS072FEE49BE00240726 /* Pods-suixinkan_iosTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan_iosTests.debug.xcconfig"; path = "Target Support Files/Pods-suixinkan_iosTests/Pods-suixinkan_iosTests.debug.xcconfig"; sourceTree = ""; }; + A1PODS082FEE49BE00240726 /* Pods-suixinkan_iosTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan_iosTests.release.xcconfig"; path = "Target Support Files/Pods-suixinkan_iosTests/Pods-suixinkan_iosTests.release.xcconfig"; sourceTree = ""; }; + A1TEST022FEE49BE00240726 /* suixinkan_iosTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkan_iosTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 933171602FEE49BE00240726 /* Exceptions for "suixinkan_ios" folder in "suixinkan_ios" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 9331714D2FEE49BE00240726 /* suixinkan_ios */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 933171502FEE49BE00240726 /* suixinkan_ios */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 933171602FEE49BE00240726 /* Exceptions for "suixinkan_ios" folder in "suixinkan_ios" target */, + ); + path = suixinkan_ios; + sourceTree = ""; + }; + A1TEST012FEE49BE00240726 /* suixinkan_iosTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = suixinkan_iosTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 9331714B2FEE49BE00240726 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 93CC3ECB2FEE4EB300EC7B8E /* AlibabaCloudOSS in Frameworks */, + 93CC3ECC2FEE4EB300EC7B8E /* Lottie in Frameworks */, + A1PODS012FEE49BE00240726 /* Pods_suixinkan_ios.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A1TEST052FEE49BE00240726 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A1PODS032FEE49BE00240726 /* Pods_suixinkan_iosTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 933171452FEE49BE00240726 = { + isa = PBXGroup; + children = ( + 933171502FEE49BE00240726 /* suixinkan_ios */, + A1TEST012FEE49BE00240726 /* suixinkan_iosTests */, + 9331714F2FEE49BE00240726 /* Products */, + A1PODS092FEE49BE00240726 /* Pods */, + A1PODS0A2FEE49BE00240726 /* Frameworks */, + ); + sourceTree = ""; + }; + 9331714F2FEE49BE00240726 /* Products */ = { + isa = PBXGroup; + children = ( + 9331714E2FEE49BE00240726 /* suixinkan_ios.app */, + A1TEST022FEE49BE00240726 /* suixinkan_iosTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + A1PODS092FEE49BE00240726 /* Pods */ = { + isa = PBXGroup; + children = ( + A1PODS052FEE49BE00240726 /* Pods-suixinkan_ios.debug.xcconfig */, + A1PODS062FEE49BE00240726 /* Pods-suixinkan_ios.release.xcconfig */, + A1PODS072FEE49BE00240726 /* Pods-suixinkan_iosTests.debug.xcconfig */, + A1PODS082FEE49BE00240726 /* Pods-suixinkan_iosTests.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + A1PODS0A2FEE49BE00240726 /* Frameworks */ = { + isa = PBXGroup; + children = ( + A1PODS022FEE49BE00240726 /* Pods_suixinkan_ios.framework */, + A1PODS042FEE49BE00240726 /* Pods_suixinkan_iosTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 9331714D2FEE49BE00240726 /* suixinkan_ios */ = { + isa = PBXNativeTarget; + buildConfigurationList = 933171612FEE49BE00240726 /* Build configuration list for PBXNativeTarget "suixinkan_ios" */; + buildPhases = ( + A1CP0012FEE49BE00240726 /* [CP] Check Pods Manifest.lock */, + 9331714A2FEE49BE00240726 /* Sources */, + 9331714B2FEE49BE00240726 /* Frameworks */, + 9331714C2FEE49BE00240726 /* Resources */, + FC8F999138656E65051391AE /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 933171502FEE49BE00240726 /* suixinkan_ios */, + ); + name = suixinkan_ios; + packageProductDependencies = ( + 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */, + 93DAED102FE9100000B9E2B1 /* Lottie */, + ); + productName = suixinkan_ios; + productReference = 9331714E2FEE49BE00240726 /* suixinkan_ios.app */; + productType = "com.apple.product-type.application"; + }; + A1TEST032FEE49BE00240726 /* suixinkan_iosTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1TEST0B2FEE49BE00240726 /* Build configuration list for PBXNativeTarget "suixinkan_iosTests" */; + buildPhases = ( + A1CP0032FEE49BE00240726 /* [CP] Check Pods Manifest.lock */, + A1TEST042FEE49BE00240726 /* Sources */, + A1TEST052FEE49BE00240726 /* Frameworks */, + A1TEST062FEE49BE00240726 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A1TEST082FEE49BE00240726 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A1TEST012FEE49BE00240726 /* suixinkan_iosTests */, + ); + name = suixinkan_iosTests; + productName = suixinkan_iosTests; + productReference = A1TEST022FEE49BE00240726 /* suixinkan_iosTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 933171462FEE49BE00240726 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + 9331714D2FEE49BE00240726 = { + CreatedOnToolsVersion = 26.5; + }; + A1TEST032FEE49BE00240726 = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 9331714D2FEE49BE00240726; + }; + }; + }; + buildConfigurationList = 933171492FEE49BE00240726 /* Build configuration list for PBXProject "suixinkan_ios" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 933171452FEE49BE00240726; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */, + 93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 9331714F2FEE49BE00240726 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 9331714D2FEE49BE00240726 /* suixinkan_ios */, + A1TEST032FEE49BE00240726 /* suixinkan_iosTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 9331714C2FEE49BE00240726 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A1TEST062FEE49BE00240726 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + A1CP0012FEE49BE00240726 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-suixinkan_ios-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + A1CP0032FEE49BE00240726 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-suixinkan_iosTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FC8F999138656E65051391AE /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan_ios/Pods-suixinkan_ios-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 9331714A2FEE49BE00240726 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A1TEST042FEE49BE00240726 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A1TEST082FEE49BE00240726 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9331714D2FEE49BE00240726 /* suixinkan_ios */; + targetProxy = A1TEST072FEE49BE00240726 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 933171622FEE49BE00240726 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A1PODS052FEE49BE00240726 /* Pods-suixinkan_ios.debug.xcconfig */; + 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_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = suixinkan_ios/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.hanqiu.suixinkan-ios"; + 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; + }; + 933171632FEE49BE00240726 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A1PODS062FEE49BE00240726 /* Pods-suixinkan_ios.release.xcconfig */; + 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_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = suixinkan_ios/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 16; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.hanqiu.suixinkan-ios"; + 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; + }; + 933171642FEE49BE00240726 /* 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 = NO; + 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 = 16; + 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; + }; + 933171652FEE49BE00240726 /* 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 = NO; + 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 = 16; + 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; + }; + A1TEST092FEE49BE00240726 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A1PODS072FEE49BE00240726 /* Pods-suixinkan_iosTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.hanqiu.suixinkan-iosTests"; + 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_ios.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan_ios"; + TEST_TARGET_NAME = suixinkan_ios; + }; + name = Debug; + }; + A1TEST0A2FEE49BE00240726 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A1PODS082FEE49BE00240726 /* Pods-suixinkan_iosTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 56GVN5RNVN; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.hanqiu.suixinkan-iosTests"; + 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_ios.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan_ios"; + TEST_TARGET_NAME = suixinkan_ios; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 933171492FEE49BE00240726 /* Build configuration list for PBXProject "suixinkan_ios" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 933171642FEE49BE00240726 /* Debug */, + 933171652FEE49BE00240726 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 933171612FEE49BE00240726 /* Build configuration list for PBXNativeTarget "suixinkan_ios" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 933171622FEE49BE00240726 /* Debug */, + 933171632FEE49BE00240726 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A1TEST0B2FEE49BE00240726 /* Build configuration list for PBXNativeTarget "suixinkan_iosTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A1TEST092FEE49BE00240726 /* Debug */, + A1TEST0A2FEE49BE00240726 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 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; + }; + }; + 93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/airbnb/lottie-ios.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.5.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */ = { + isa = XCSwiftPackageProductDependency; + package = 93DAED0A2FE8E17E00B9E2B1 /* XCRemoteSwiftPackageReference "alibabacloud-oss-swift-sdk-v2" */; + productName = AlibabaCloudOSS; + }; + 93DAED102FE9100000B9E2B1 /* Lottie */ = { + isa = XCSwiftPackageProductDependency; + package = 93DAED0F2FE9100000B9E2B1 /* XCRemoteSwiftPackageReference "lottie-ios" */; + productName = Lottie; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 933171462FEE49BE00240726 /* Project object */; +} diff --git a/suixinkan_ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/suixinkan_ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/suixinkan_ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/suixinkan_ios.xcodeproj/xcshareddata/xcschemes/suixinkan_ios.xcscheme b/suixinkan_ios.xcodeproj/xcshareddata/xcschemes/suixinkan_ios.xcscheme new file mode 100644 index 0000000..86f740b --- /dev/null +++ b/suixinkan_ios.xcodeproj/xcshareddata/xcschemes/suixinkan_ios.xcscheme @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/suixinkan_ios.xcworkspace/contents.xcworkspacedata b/suixinkan_ios.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..5e19aaf --- /dev/null +++ b/suixinkan_ios.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/suixinkan_ios/App/AMapBootstrap.swift b/suixinkan_ios/App/AMapBootstrap.swift new file mode 100644 index 0000000..1206486 --- /dev/null +++ b/suixinkan_ios/App/AMapBootstrap.swift @@ -0,0 +1,36 @@ +// +// AMapBootstrap.swift +// suixinkan +// +// 高德 SDK 初始化入口。真机构建时 AMAP_ENABLED 生效;模拟器构建时不链接 AMap。 +// + +import Foundation + +#if AMAP_ENABLED +import AMapFoundationKit +import AMapLocationKit +import AMapSearchKit +import MAMapKit + +enum AMapBootstrap { + /// 在 App 启动时调用一次。将 `apiKey` 替换为高德控制台申请的 Key。 + static func configure(apiKey: String) { + guard !apiKey.isEmpty else { return } + + AMapServices.shared().apiKey = apiKey + AMapServices.shared().enableHTTPS = true + + AMapSearchAPI.updatePrivacyShow(.didShow, privacyInfo: .didContain) + AMapSearchAPI.updatePrivacyAgree(.didAgree) + MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain) + MAMapView.updatePrivacyAgree(.didAgree) + AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain) + AMapLocationManager.updatePrivacyAgree(.didAgree) + } +} +#else +enum AMapBootstrap { + static func configure(apiKey: String) {} +} +#endif diff --git a/suixinkan_ios/App/App.md b/suixinkan_ios/App/App.md new file mode 100644 index 0000000..e43201c --- /dev/null +++ b/suixinkan_ios/App/App.md @@ -0,0 +1,79 @@ +# 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. 普通网络失败时保留本地登录态,使用账号快照进入主界面。 + +## UI Test 启动约定 + +- `AppUITestLaunchState` 在收到 `-suixinkan-ui-tests` 时跳过推送注册和排队 WebSocket,避免系统弹窗干扰自动化。 +- `-suixinkan-ui-tests-reset-state` 用于冷启动清理 Keychain 与 UserDefaults。 +- `-suixinkan-ui-tests-open-menu <菜单标题>` 登录后直达首页调试目录中的目标页。 +- `-suixinkan-ui-tests-open-profile <路由名>` 登录后直达个人中心二级页(如 `settings`、`realNameAuth`)。 +- `AppUITestRouteDriver` 仅在 DEBUG 构建下解析上述直达参数,供 XCUITest 逐页验证。 +- 详细运行方式见 `suixinkanUITests/README.md`。 + +## 登录和退出 + +登录完成由 `AuthSessionCoordinator.completeLogin` 统一处理: +- 正式 token 写入 `SessionTokenStore`。 +- 上次手机号和协议状态写入 `AppPreferencesStore`。 +- 账号资料、景区列表、门店列表写入 `AccountContext`。 +- 角色权限和当前角色写入 `PermissionContext`。 +- 非敏感账号快照写入 `AccountSnapshotStore`。 +- `AppSession` 切换为 `loggedIn`。 + +退出登录由 `AuthSessionCoordinator.logout` 统一处理: +- 清空 Keychain token。 +- 清空账号快照。 +- 重置账号上下文、权限上下文、景点上下文、导航路径和 Toast。 +- `AppSession` 切换为 `loggedOut`。 +- 保留上次手机号、协议状态等非敏感偏好。 + +## Toast + +全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。 + +Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。 + +## 导航规则 + +主界面使用 `TabView`,每个 Tab 内部由单独的 `NavigationStack` 包裹。`AppRouter` 为每个 `AppTab` 持有独立 `RouterPath`,切换 Tab 不会丢失该 Tab 的内部导航路径。 + +Tab 根页面显示底部 TabBar。通过 `AppRoute` push 到子页面时,`MainTabsView` 会根据 `AppRoute.hidesTabBarWhenPushed` 统一隐藏 TabBar,避免每个业务页面重复处理。 + +当前路由枚举 `AppRoute` 仍以占位详情页为主,后续新增真实页面时应优先扩展 `AppRoute`,再由对应 Tab 的 `NavigationStack` 处理跳转。 diff --git a/suixinkan_ios/App/AppServices.swift b/suixinkan_ios/App/AppServices.swift new file mode 100644 index 0000000..2a683b2 --- /dev/null +++ b/suixinkan_ios/App/AppServices.swift @@ -0,0 +1,123 @@ +// +// AppServices.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import Foundation + +@MainActor +/// 全局依赖容器,集中提供 ViewModel 与 ViewController 所需的 API 和上下文。 +final class AppServices { + static let shared = AppServices() + + let appSession = AppSession() + let accountContext = AccountContext() + let permissionContext = PermissionContext() + let scenicSpotContext = ScenicSpotContext() + let appRouter = AppRouter() + let toastCenter = ToastCenter() + let globalLoading = GlobalLoadingCenter() + let scenicQueueRuntime = ScenicQueueRuntime() + + let apiClient: APIClient + let authAPI: AuthAPI + let profileAPI: ProfileAPI + let uploadAPI: UploadAPI + let ossUploadService: OSSUploadService + let accountContextAPI: AccountContextAPI + let ordersAPI: OrdersAPI + let statisticsAPI: StatisticsAPI + let paymentAPI: PaymentAPI + let walletAPI: WalletAPI + let pushAPI: PushAPI + let scenicPermissionAPI: ScenicPermissionAPI + let scenicSettlementAPI: ScenicSettlementAPI + let messageCenterAPI: MessageCenterAPI + let scenicQueueAPI: ScenicQueueAPI + let liveAPI: LiveAPI + let operatingAreaAPI: OperatingAreaAPI + let pilotCertificationAPI: PilotCertificationAPI + let taskAPI: TaskAPI + let projectAPI: ProjectAPI + let scheduleAPI: ScheduleAPI + let inviteAPI: InviteAPI + let assetsAPI: AssetsAPI + let punchPointAPI: PunchPointAPI + let locationReportAPI: LocationReportAPI + + let authSessionCoordinator: AuthSessionCoordinator + let sessionBootstrapper: SessionBootstrapper + private let tokenStore: SessionTokenStore + private let snapshotStore: AccountSnapshotStore + + /// 当前景区 ID。 + var currentScenicId: Int? { + accountContext.currentScenic?.id + } + + /// 当前用户 staffId,用于钱包等接口。 + var staffId: Int? { + Int(accountContext.profile?.userId ?? "") + } + + /// 当前用户 ID 字符串。 + var userId: String? { + accountContext.profile?.userId + } + + private init() { + let client = APIClient() + let tokenStore = SessionTokenStore() + let snapshotStore = AccountSnapshotStore() + self.tokenStore = tokenStore + self.snapshotStore = snapshotStore + apiClient = client + authAPI = AuthAPI(client: client) + profileAPI = ProfileAPI(client: client) + uploadAPI = UploadAPI(client: client) + ossUploadService = OSSUploadService(configService: uploadAPI) + accountContextAPI = AccountContextAPI(client: client) + ordersAPI = OrdersAPI(client: client) + statisticsAPI = StatisticsAPI(client: client) + paymentAPI = PaymentAPI(client: client) + walletAPI = WalletAPI(client: client) + pushAPI = PushAPI(client: client) + scenicPermissionAPI = ScenicPermissionAPI(client: client) + scenicSettlementAPI = ScenicSettlementAPI(client: client) + messageCenterAPI = MessageCenterAPI(client: client) + scenicQueueAPI = ScenicQueueAPI(client: client) + liveAPI = LiveAPI(client: client) + operatingAreaAPI = OperatingAreaAPI(client: client) + pilotCertificationAPI = PilotCertificationAPI(client: client) + taskAPI = TaskAPI(client: client) + projectAPI = ProjectAPI(client: client) + scheduleAPI = ScheduleAPI(client: client) + inviteAPI = InviteAPI(client: client) + assetsAPI = AssetsAPI(client: client) + punchPointAPI = PunchPointAPI(client: client) + locationReportAPI = LocationReportAPI(client: client) + authSessionCoordinator = AuthSessionCoordinator( + tokenStore: tokenStore, + snapshotStore: snapshotStore, + preferencesStore: AppPreferencesStore() + ) + sessionBootstrapper = SessionBootstrapper( + tokenStore: tokenStore, + snapshotStore: snapshotStore + ) + apiClient.bindAuthTokenProvider { [unowned self] in + appSession.token + } + } + + /// 绑定推送与 UIKit 导航。 + func configurePushNotifications() { + PushNotificationManager.shared.configure( + api: pushAPI, + session: appSession, + router: appRouter + ) + } +} diff --git a/suixinkan_ios/App/AppUITestLaunchState.swift b/suixinkan_ios/App/AppUITestLaunchState.swift new file mode 100644 index 0000000..a8d3dc2 --- /dev/null +++ b/suixinkan_ios/App/AppUITestLaunchState.swift @@ -0,0 +1,55 @@ +// +// AppUITestLaunchState.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import Foundation + +/// UI 测试启动状态,仅在测试进程显式传入启动参数时生效。 +enum AppUITestLaunchState { + /// UI Test 主开关,测试进程统一传入。 + static let uiTestsArgument = "-suixinkan-ui-tests" + /// 冷启动前清理本地登录缓存。 + static let resetArgument = "-suixinkan-ui-tests-reset-state" + /// 登录后直达首页调试菜单项(下一参数为菜单标题)。 + static let openMenuArgument = "-suixinkan-ui-tests-open-menu" + /// 登录后直达个人中心二级页(下一参数为路由名)。 + static let openProfileArgument = "-suixinkan-ui-tests-open-profile" + + /// 当前进程是否由 XCUITest 启动。 + static var isRunningUITests: Bool { + ProcessInfo.processInfo.arguments.contains(uiTestsArgument) + } + + /// 待打开的首页调试菜单标题。 + static var pendingMenuTitle: String? { + argumentValue(following: openMenuArgument) + } + + /// 待打开的个人中心路由标识。 + static var pendingProfileRouteRawValue: String? { + argumentValue(following: openProfileArgument) + } + + /// 读取启动参数中紧跟在指定 flag 后的值。 + private static func argumentValue(following flag: String) -> String? { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else { + return nil + } + let value = arguments[index + 1].trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + /// 按需在冷启动时清理 token、账号快照和偏好设置。 + static func resetIfNeeded(arguments: [String] = ProcessInfo.processInfo.arguments) { + guard arguments.contains(resetArgument) else { return } + + try? SessionTokenStore().clear() + AccountSnapshotStore().clear() + let preferences = AppPreferencesStore() + preferences.clear() + } +} diff --git a/suixinkan_ios/App/AppUITestRouteDriver.swift b/suixinkan_ios/App/AppUITestRouteDriver.swift new file mode 100644 index 0000000..fd1bbe3 --- /dev/null +++ b/suixinkan_ios/App/AppUITestRouteDriver.swift @@ -0,0 +1,95 @@ +// +// AppUITestRouteDriver.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +#if DEBUG +import UIKit + +/// UI Test 专用路由直达器,通过启动参数打开目标页,绕开自定义 TabBar 下 XCUITest 难以触发的 push。 +@MainActor +enum AppUITestRouteDriver { + private static var didApply = false + + /// 根据启动参数跳转到指定首页菜单或个人中心页面。 + static func applyIfNeeded(services: AppServices = .shared) async { + guard AppUITestLaunchState.isRunningUITests, !didApply else { return } + guard AppUITestLaunchState.pendingMenuTitle != nil + || AppUITestLaunchState.pendingProfileRouteRawValue != nil else { return } + + didApply = true + try? await Task.sleep(nanoseconds: 1_200_000_000) + + if let menuTitle = AppUITestLaunchState.pendingMenuTitle { + openHomeMenu(title: menuTitle, services: services) + return + } + + if let rawValue = AppUITestLaunchState.pendingProfileRouteRawValue { + openProfileRoute(rawValue: rawValue, services: services) + } + } + + /// 打开首页调试目录中的指定菜单。 + private static func openHomeMenu(title: String, services: AppServices) { + let resolvedTitle = resolveMenuTitle(title) + guard let item = HomeMenuRouter.debugAllMenuItems().first(where: { $0.title == resolvedTitle }) else { + return + } + + services.appRouter.reset() + let resolved = HomeMenuRouter.resolve(uri: item.uri, title: item.title) + switch resolved { + case .tab(let tab): + services.appRouter.select(tab) + case .orders(let entry): + services.appRouter.selectOrders(entry: entry) + case .destination(let homeRoute): + services.appRouter.navigateHome(homeRoute, animated: false) + case .unsupported(let uri, let title, _): + services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false) + case .placeholder(let uri, let title): + services.appRouter.navigateHome(.modulePlaceholder(uri: uri, title: title), animated: false) + } + } + + /// 打开个人中心二级页面。 + private static func openProfileRoute(rawValue: String, services: AppServices) { + let route: ProfileRoute? + switch rawValue { + case "settings": + route = .settings + case "realNameAuth": + route = .realNameAuth + case "accountSwitch": + route = .accountSwitch + case "debugHomeMenus": + route = .debugHomeMenus + case "agreement.about": + route = .agreement(.about) + case "agreement.userAgreement": + route = .agreement(.userAgreement) + case "agreement.privacyPolicy": + route = .agreement(.privacyPolicy) + default: + route = nil + } + + guard let route else { return } + services.appRouter.reset() + services.appRouter.navigateProfile(route, animated: false) + } + + /// 将 UI Test 用例菜单名映射为调试目录标题。 + private static func resolveMenuTitle(_ title: String) -> String { + switch title { + case "系统设置": + return "设置中心" + default: + return title + } + } +} +#endif diff --git a/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift b/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift new file mode 100644 index 0000000..cbe8ba6 --- /dev/null +++ b/suixinkan_ios/App/Navigation/AppRouteViewControllerFactory.swift @@ -0,0 +1,181 @@ +// +// AppRouteViewControllerFactory.swift +// suixinkan +// + +import UIKit + +@MainActor +/// 应用路由 ViewController 工厂,将 `AppRoute` 及各模块路由映射为 UIKit 页面。 +enum AppRouteViewControllerFactory { + + /// 根据路由创建目标 ViewController;未迁移页面使用占位页。 + static func makeViewController(for route: AppRoute, services: AppServices) -> UIViewController { + switch route { + case .placeholder(let title): + return PlaceholderViewController(title: title) + case .home(let homeRoute): + return makeViewController(for: homeRoute, services: services) + case .profile(let profileRoute): + return makeViewController(for: profileRoute, services: services) + case .orders(let ordersRoute): + return makeViewController(for: ordersRoute, services: services) + } + } + + /// 根据首页二级路由创建 ViewController。 + static func makeViewController(for route: HomeRoute, services: AppServices) -> UIViewController { + switch route { + case .profileSpace: + return ProfileViewController() + case .scenicSelection: + return ScenicSelectionViewController() + case .permissionApply: + return PermissionApplyViewController() + case .permissionApplyStatus: + return PermissionApplyStatusViewController() + case .scenicApplication: + return ScenicApplicationViewController() + case .moreFunctions: + return HomeMoreFunctionsViewController() + case .settings: + return SettingsViewController() + case .paymentCollection: + return PaymentCollectionViewController() + case .wallet: + return WalletViewController() + case .taskManagement: + return TaskManagementViewController() + case .taskCreate: + return TaskCreateViewController() + case .taskDetail(let id, let summary): + return TaskDetailViewController(taskId: id, summary: summary) + case .projectManagement: + return ProjectManagementViewController() + case .pmProjectManagement: + return StoreProjectManagementViewController() + case .projectDetail(let id, let storeMode): + return ProjectDetailViewController(projectId: id, storeMode: storeMode) + case .projectEditor(let id, let storeMode): + if storeMode { + return StoreProjectEditorViewController(projectId: id) + } + return ProjectEditorViewController(projectId: id) + case .scheduleManagement: + return ScheduleManagementViewController() + case .scheduleAdd: + return ScheduleAddViewController() + case .photographerInvite: + return PhotographerInviteViewController() + case .inviteRecord: + return InviteRecordViewController() + case .cloudStorage: + return CloudStorageViewController() + case .cloudStorageTransit: + return CloudStorageTransitViewController() + case .materialLibrary: + return MediaLibraryViewController(kind: .material) + case .materialUpload: + return MediaLibraryUploadViewController(kind: .material) + case .sampleLibrary: + return MediaLibraryViewController(kind: .sample) + case .sampleUpload: + return MediaLibraryUploadViewController(kind: .sample) + case .albumList: + return AlbumListViewController() + case .albumTrailer: + return AlbumTrailerViewController() + case .punchPointList: + return PunchPointListViewController() + case .punchPointDetail(let id, let summary): + return PunchPointDetailViewController(punchPointId: id, summary: summary) + case .punchPointEditor(let id): + return PunchPointEditorViewController(punchPointId: id) + case .punchPointQR(_, let title, let qrURL): + return PunchPointQRViewController(title: title, qrURL: qrURL) + case .locationReport: + return LocationReportViewController() + case .locationReportHistory: + return LocationReportHistoryViewController() + case .depositOrders: + return DepositOrderListViewController() + case .withdrawalAudit: + return WithdrawalAuditViewController() + case .scenicSettlement: + return ScenicSettlementViewController() + case .scenicSettlementReview: + return ScenicSettlementReviewViewController() + case .messageCenter: + return MessageCenterViewController() + case .queueManagement: + return QueueManagementViewController() + case .liveManagement: + return LiveManagementViewController() + case .liveAlbum: + return LiveAlbumViewController() + case .operatingArea: + return OperatingAreaViewController() + case .pilotCertification: + return PilotCertificationViewController() + case .modulePlaceholder(_, let title): + return FeaturePlaceholderViewController(title: title, uri: homeRouteURI(route)) + } + } + + /// 根据个人中心二级路由创建 ViewController。 + static func makeViewController(for route: ProfileRoute, services: AppServices) -> UIViewController { + switch route { + case .accountSwitch: + return AccountSwitchViewController() + case .realNameAuth: + return RealNameAuthViewController() + case .settings: + return SettingsViewController() + case .agreement(let page): + return AgreementViewController(page: page) + #if DEBUG + case .debugHomeMenus: + return HomeMoreFunctionsViewController() + #endif + } + } + + /// 根据订单模块二级路由创建 ViewController。 + static func makeViewController(for route: OrdersRoute, services: AppServices) -> UIViewController { + switch route { + case .storeDetail(let item): + return StoreOrderDetailViewController(item: item) + case .writeOffDetail(let item): + return WriteOffOrderDetailViewController(item: item) + case .depositDetail(let orderNumber): + return DepositOrderDetailViewController(orderNumber: orderNumber) + case .depositShootingInfo(let orderNumber, let scenicSpotId, let photogUid): + return DepositOrderShootingInfoViewController( + orderNumber: orderNumber, + scenicSpotId: scenicSpotId, + photogUid: photogUid + ) + case .historicalShooting(let orderNumber): + return HistoricalShootingInfoViewController(orderNumber: orderNumber) + case .multiTravelTaskUpload(let orderNumber): + return MultiTravelTaskUploadViewController(initialOrderNumber: orderNumber) + case .orderTrailer(let orderNumber, let title): + return FeaturePlaceholderViewController(title: title, uri: "order_trailer:\(orderNumber)") + } + } + + private static func homeRouteURI(_ route: HomeRoute) -> String { + if case let .modulePlaceholder(uri, _) = route { + return uri + } + return String(describing: route) + } +} + +@MainActor +/// 首页路由 ViewController 工厂,供 `UIKitAppNavigation` 复用。 +enum HomeRouteViewControllerFactory { + static func make(for route: HomeRoute) -> UIViewController { + AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared) + } +} diff --git a/suixinkan_ios/App/Navigation/AppTab.swift b/suixinkan_ios/App/Navigation/AppTab.swift new file mode 100644 index 0000000..f02307f --- /dev/null +++ b/suixinkan_ios/App/Navigation/AppTab.swift @@ -0,0 +1,58 @@ +// +// AppTab.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import UIKit + +/// 主 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" + } + } + + /// 构建 Tab 根页面 ViewController。 + func makeRootViewController(services: AppServices) -> UIViewController { + switch self { + case .home: + return HomeViewController() + case .orders: + return OrdersViewController() + case .statistics: + return StatisticsViewController() + case .profile: + return ProfileViewController() + } + } +} diff --git a/suixinkan_ios/App/Navigation/NavigationRouter.swift b/suixinkan_ios/App/Navigation/NavigationRouter.swift new file mode 100644 index 0000000..998ba00 --- /dev/null +++ b/suixinkan_ios/App/Navigation/NavigationRouter.swift @@ -0,0 +1,109 @@ +// +// NavigationRouter.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 应用内可导航目标,承载各 Tab 内部的路由目的地。 +enum AppRoute: Hashable { + case placeholder(title: String) + case home(HomeRoute) + case profile(ProfileRoute) + case orders(OrdersRoute) + + /// 返回该路由通过 push 后是否隐藏底部 TabBar。 + var hidesTabBarWhenPushed: Bool { + true + } +} + +@MainActor +/// 单个导航栈的路径容器,用于保存某个 Tab 的导航历史。 +final class RouterPath { + var onChange: (() -> Void)? + var path: [AppRoute] = [] { didSet { onChange?() } } + + /// 将指定路由压入当前 Tab 的导航栈。 + func navigate(to route: AppRoute) { + path.append(route) + } + + /// 从当前 Tab 的导航栈弹出最后一级路由。 + func navigateBack() { + guard !path.isEmpty else { return } + path.removeLast() + } + + /// 清空当前 Tab 的导航栈。 + func reset() { + path = [] + } +} + +@MainActor +/// 主导航状态中心,管理当前 Tab 和每个 Tab 独立的导航路径。 +final class AppRouter { + var onChange: (() -> Void)? + var selectedTab: AppTab = .home { didSet { onChange?() } } + var selectedOrdersEntry: OrdersEntry = .storeOrders { didSet { onChange?() } } + private(set) var pendingOrderScanCode: String? { didSet { onChange?() } } + + private let routers: [AppTab: RouterPath] + + init() { + var builtRouters: [AppTab: RouterPath] = [:] + for tab in AppTab.allCases { + builtRouters[tab] = RouterPath() + } + routers = builtRouters + routers.values.forEach { router in + router.onChange = { [weak self] in + self?.onChange?() + } + } + } + + /// 获取指定 Tab 对应的路由路径容器。 + func router(for tab: AppTab) -> RouterPath { + guard let router = routers[tab] else { + assertionFailure("Missing router path for tab: \(tab)") + return RouterPath() + } + return router + } + + /// 切换到指定主 Tab。 + func select(_ tab: AppTab) { + selectedTab = tab + } + + /// 切换到订单 Tab,并指定订单内部子入口。 + func selectOrders(entry: OrdersEntry) { + selectedOrdersEntry = entry + selectedTab = .orders + } + + /// 切换到核销订单入口,并暂存全局扫码结果等待订单页消费。 + func routeToOrderVerification(scannedCode: String) { + pendingOrderScanCode = scannedCode + selectOrders(entry: .verificationOrders) + } + + /// 读取并清空待处理的全局扫码结果,确保同一次扫码只被处理一次。 + func consumePendingOrderScanCode() -> String? { + let code = pendingOrderScanCode + pendingOrderScanCode = nil + return code + } + + /// 重置主 Tab 选择和所有 Tab 的导航历史。 + func reset() { + selectedTab = .home + selectedOrdersEntry = .storeOrders + pendingOrderScanCode = nil + routers.values.forEach { $0.reset() } + } +} diff --git a/suixinkan_ios/App/Navigation/TabNavigationController.swift b/suixinkan_ios/App/Navigation/TabNavigationController.swift new file mode 100644 index 0000000..1f49cf1 --- /dev/null +++ b/suixinkan_ios/App/Navigation/TabNavigationController.swift @@ -0,0 +1,111 @@ +// +// TabNavigationController.swift +// suixinkan +// + +import UIKit + +@MainActor +/// 单个 Tab 的导航栈,与 `AppRouter` 路径同步并在 push 时隐藏底部 TabBar。 +final class TabNavigationController: UINavigationController, UINavigationControllerDelegate { + + let appTab: AppTab + private let services: AppServices + private var isSyncingStack = false + + weak var tabBarHost: MainTabBarController? + + init(tab: AppTab, services: AppServices) { + self.appTab = tab + self.services = services + super.init(nibName: nil, bundle: nil) + delegate = self + navigationBar.prefersLargeTitles = false + setViewControllers([tab.makeRootViewController(services: services)], animated: false) + registerNavigationBridge() + services.appRouter.router(for: appTab).onChange = { [weak self] in + self?.syncFromRouterPath() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Push 指定应用路由,并同步写入 RouterPath。 + func pushRoute(_ route: AppRoute, animated: Bool = true) { + let viewController = AppRouteViewControllerFactory.makeViewController(for: route, services: services) + viewController.hidesBottomBarWhenPushed = route.hidesTabBarWhenPushed + isSyncingStack = true + services.appRouter.router(for: appTab).navigate(to: route) + isSyncingStack = false + pushViewController(viewController, animated: animated) + } + + func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + let isRoot = viewController === viewControllers.first + tabBarHost?.setCustomTabBarHidden(!isRoot, animated: animated) + } + + func navigationController( + _ navigationController: UINavigationController, + didShow viewController: UIViewController, + animated: Bool + ) { + syncRouterPathFromStack() + } + + private func registerNavigationBridge() { + switch appTab { + case .home: + UIKitAppNavigation.homeNavigationController = self + case .profile: + UIKitAppNavigation.profileNavigationController = self + default: + break + } + } + + private func syncFromRouterPath() { + guard !isSyncingStack else { return } + + let routerPath = services.appRouter.router(for: appTab) + let targetDepth = routerPath.path.count + let currentDepth = max(viewControllers.count - 1, 0) + + if targetDepth > currentDepth { + for index in currentDepth.. Void)? + private(set) var profile: AccountProfile? { didSet { onChange?() } } + private(set) var scenicScopes: [BusinessScope] = [] { didSet { onChange?() } } + private(set) var storeScopes: [BusinessScope] = [] { didSet { onChange?() } } + var currentScenic: BusinessScope? { didSet { onChange?() } } + var currentStore: BusinessScope? { didSet { onChange?() } } + + /// 应用登录成功后写入账号资料。 + func applyLogin(profile: AccountProfile? = nil) { + self.profile = profile + } + + /// 替换当前账号资料,通常用于用户信息刷新后同步全局展示。 + func replaceProfile(_ profile: AccountProfile?) { + self.profile = profile + } + + /// 替换景区和门店作用域,并尽量保持当前选择不变。 + func replaceScopes( + scenic: [BusinessScope], + stores: [BusinessScope], + currentScenicId: Int? = nil, + currentStoreId: Int? = nil + ) { + scenicScopes = scenic + storeScopes = stores + currentScenic = currentScenicId.flatMap { id in + scenic.first { $0.id == id } + } ?? currentScenic.flatMap { current in + scenic.first { $0.id == current.id } + } ?? scenic.first + currentStore = resolvedStore( + in: stores, + currentScenic: currentScenic, + preferredStoreId: currentStoreId ?? currentStore?.id + ) + } + + /// 切换当前景区,并按景区重新校准当前门店。 + func selectScenic(id scenicId: Int) { + guard let scenic = scenicScopes.first(where: { $0.id == scenicId }) else { return } + currentScenic = scenic + currentStore = resolvedStore( + in: storeScopes, + currentScenic: scenic, + preferredStoreId: currentStore?.id + ) + } + + /// 切换当前门店,并在门店有关联景区时同步当前景区。 + func selectStore(id storeId: Int) { + guard let store = storeScopes.first(where: { $0.id == storeId }) else { return } + currentStore = store + if let scenicId = store.parentScenicId, + currentScenic?.id != scenicId, + let scenic = scenicScopes.first(where: { $0.id == scenicId }) { + currentScenic = scenic + } + } + + /// 清空账号上下文,通常在退出登录时调用。 + func reset() { + profile = nil + scenicScopes = [] + storeScopes = [] + currentScenic = nil + currentStore = nil + } + + /// 按当前景区和期望门店 ID 选择有效门店。 + private func resolvedStore( + in stores: [BusinessScope], + currentScenic: BusinessScope?, + preferredStoreId: Int? + ) -> BusinessScope? { + let scenicId = currentScenic?.id + let storesForScenic = stores.filter { store in + guard let scenicId else { return true } + return store.parentScenicId == nil || store.parentScenicId == scenicId + } + if let preferredStoreId, + let store = storesForScenic.first(where: { $0.id == preferredStoreId }) { + return store + } + return storesForScenic.first + } +} diff --git a/suixinkan_ios/App/State/AccountContextLoader.swift b/suixinkan_ios/App/State/AccountContextLoader.swift new file mode 100644 index 0000000..2981263 --- /dev/null +++ b/suixinkan_ios/App/State/AccountContextLoader.swift @@ -0,0 +1,115 @@ +// +// AccountContextLoader.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 用户资料服务协议,抽象用户信息读取能力以便测试替换。 +protocol UserProfileServing { + /// 获取当前登录账号的用户资料。 + func userInfo() async throws -> UserInfoResponse +} + +@MainActor +/// 账号上下文加载器,统一装配用户资料、角色权限、景区和门店上下文。 +struct AccountContextLoader { + /// 刷新账号上下文,权限失败会向外抛错,景区和门店按旧工程规则兜底。 + func refresh( + accountContext: AccountContext, + permissionContext: PermissionContext, + profileAPI: UserProfileServing, + accountContextAPI: AccountContextServing, + cachedCurrentRoleId: Int? = nil, + cachedCurrentScenicId: Int? = nil, + cachedCurrentStoreId: Int? = nil + ) async throws { + async let userData = profileAPI.userInfo() + async let roleData = accountContextAPI.rolePermissions() + let (userInfo, rolePermissions) = try await (userData, roleData) + + permissionContext.replaceRolePermissions(rolePermissions, currentRoleId: cachedCurrentRoleId) + + let scenicResponse = await loadScenicList(accountContextAPI: accountContextAPI, rolePermissions: rolePermissions) + let storesResponse = await loadStores(accountContextAPI: accountContextAPI) + let profile = makeProfile(from: userInfo, fallback: accountContext.profile) + let scenicScopes = scopedScenics( + permissionContext: permissionContext, + scenicResponse: scenicResponse + ) + let storeScopes = storesResponse?.list.map { store in + BusinessScope(id: store.id, name: store.name, kind: .store, parentScenicId: store.scenicId) + } ?? [] + + accountContext.replaceProfile(profile) + accountContext.replaceScopes( + scenic: scenicScopes, + stores: storeScopes, + currentScenicId: cachedCurrentScenicId, + currentStoreId: cachedCurrentStoreId + ) + } + + /// 景区列表接口失败时,从角色权限中的景区数据去重构造兜底列表。 + private func loadScenicList( + accountContextAPI: AccountContextServing, + rolePermissions: [RolePermissionResponse] + ) async -> ScenicListAllResponse { + do { + return try await accountContextAPI.scenicListAll() + } catch { + let fallback = uniqueScenics(from: rolePermissions) + return ScenicListAllResponse(total: fallback.count, list: fallback) + } + } + + /// 门店列表接口失败时返回 nil,不阻断登录和恢复流程。 + private func loadStores(accountContextAPI: AccountContextServing) async -> ListPayload? { + try? await accountContextAPI.storeAll() + } + + /// 根据当前角色优先返回角色可访问景区,角色无景区时使用景区列表接口结果。 + private func scopedScenics( + permissionContext: PermissionContext, + scenicResponse: ScenicListAllResponse + ) -> [BusinessScope] { + let roleScenics = permissionContext.currentRoleScenicScopes() + if !roleScenics.isEmpty { + return roleScenics + } + return scenicResponse.list.map { + BusinessScope(id: $0.id, name: $0.name, kind: .scenic) + } + } + + /// 从角色权限中去重生成景区列表。 + private func uniqueScenics(from rolePermissions: [RolePermissionResponse]) -> [ScenicListItem] { + var seen = Set() + var result: [ScenicListItem] = [] + for permission in rolePermissions { + for scenic in permission.scenic where seen.insert(scenic.id).inserted { + result.append(ScenicListItem(id: scenic.id, name: scenic.name)) + } + } + return result + } + + /// 将用户资料接口响应转换为全局账号资料。 + private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile { + AccountProfile( + userId: fallback?.userId ?? "", + displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称", + phone: nonEmpty(userInfo.phone) ?? fallback?.phone, + avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL + ) + } + + /// 去除空白字符后返回非空字符串。 + private func nonEmpty(_ value: String?) -> String? { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan_ios/App/State/AppSession.swift b/suixinkan_ios/App/State/AppSession.swift new file mode 100644 index 0000000..3df756a --- /dev/null +++ b/suixinkan_ios/App/State/AppSession.swift @@ -0,0 +1,46 @@ +// +// AppSession.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 登录阶段实体,表示根视图当前应该展示的认证状态。 +enum AuthPhase: Equatable { + case loggedOut + case restoring + case loggedIn +} + +@MainActor +/// 登录会话状态中心,保存 token 和当前认证阶段。 +final class AppSession { + + var onChange: (() -> Void)? + private(set) var phase: AuthPhase = .loggedOut { didSet { onChange?() } } + private(set) var token: String? { didSet { onChange?() } } + + var isLoggedIn: Bool { + phase == .loggedIn + } + + /// 将会话切换到恢复中状态,用于冷启动恢复登录态。 + func beginRestoring(token: String? = nil) { + self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines) + phase = .restoring + } + + /// 标记登录成功并保存正式 token。 + func markLoggedIn(token: String? = nil) { + self.token = token?.trimmingCharacters(in: .whitespacesAndNewlines) + phase = .loggedIn + } + + /// 清空 token 并回到未登录状态。 + func logout() { + token = nil + phase = .loggedOut + } +} diff --git a/suixinkan_ios/App/State/AuthSessionCoordinator.swift b/suixinkan_ios/App/State/AuthSessionCoordinator.swift new file mode 100644 index 0000000..368901a --- /dev/null +++ b/suixinkan_ios/App/State/AuthSessionCoordinator.swift @@ -0,0 +1,209 @@ +// +// AuthSessionCoordinator.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation + +@MainActor +/// 登录会话协调器,统一处理登录完成、退出登录和缓存同步。 +final class AuthSessionCoordinator { + private let tokenStore: SessionTokenStore + private let snapshotStore: AccountSnapshotStore + private let preferencesStore: AppPreferencesStore + + /// 初始化登录会话协调器,并注入 token、账号快照和偏好存储。 + init( + tokenStore: SessionTokenStore, + snapshotStore: AccountSnapshotStore, + preferencesStore: AppPreferencesStore + ) { + self.tokenStore = tokenStore + self.snapshotStore = snapshotStore + self.preferencesStore = preferencesStore + } + + /// 创建使用默认存储服务的登录会话协调器。 + convenience init() { + self.init( + tokenStore: SessionTokenStore(), + snapshotStore: AccountSnapshotStore(), + preferencesStore: AppPreferencesStore() + ) + } + + /// 读取登录页需要的本地偏好。 + func loginPreferences() -> LoginPreferences { + LoginPreferences( + lastUsername: preferencesStore.loadLastLoginUsername(), + privacyAgreementAccepted: preferencesStore.loadPrivacyAgreementAccepted() + ) + } + + /// 完成正式登录,写入内存状态、Keychain token 和账号快照。 + func completeLogin( + with response: V9AuthResponse, + username: String, + privacyAgreementAccepted: Bool, + appSession: AppSession, + accountContext: AccountContext, + permissionContext: PermissionContext, + profileAPI: UserProfileServing, + accountContextAPI: AccountContextServing + ) async throws { + let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) + try tokenStore.save(token) + preferencesStore.saveLastLoginUsername(username) + preferencesStore.savePrivacyAgreementAccepted(privacyAgreementAccepted) + + let profile = response.primaryProfile + let scenicScopes = response.scenicScopes + let storeScopes = response.storeScopes + let identity = response.currentAccountIdentity + accountContext.applyLogin(profile: profile) + accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes) + appSession.beginRestoring(token: token) + + do { + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: profileAPI, + accountContextAPI: accountContextAPI, + cachedCurrentScenicId: accountContext.currentScenic?.id, + cachedCurrentStoreId: accountContext.currentStore?.id + ) + } catch { + if APIError.isAuthenticationExpired(error) { + try? tokenStore.clear() + snapshotStore.clear() + accountContext.reset() + permissionContext.reset() + appSession.logout() + throw error + } + appSession.markLoggedIn(token: token) + saveSnapshot( + profile: accountContext.profile ?? profile, + accountType: identity?.accountType, + businessUserId: identity?.businessUserId, + permissionContext: permissionContext, + accountContext: accountContext + ) + throw error + } + + saveSnapshot( + profile: accountContext.profile ?? profile, + accountType: identity?.accountType, + businessUserId: identity?.businessUserId, + permissionContext: permissionContext, + accountContext: accountContext + ) + appSession.markLoggedIn(token: token) + } + + /// 使用最新用户资料刷新账号快照中的展示信息。 + func refreshCachedProfile(from userInfo: UserInfoResponse, accountContext: AccountContext) { + let profile = makeProfile(from: userInfo, fallback: accountContext.profile) + accountContext.replaceProfile(profile) + saveSnapshot( + profile: profile, + accountType: snapshotStore.load()?.accountType, + businessUserId: snapshotStore.load()?.businessUserId, + currentRoleId: snapshotStore.load()?.currentRoleId, + accountContext: accountContext + ) + } + + /// 退出登录并清空敏感缓存,保留手机号和协议状态等偏好。 + func logout( + appSession: AppSession, + accountContext: AccountContext, + permissionContext: PermissionContext, + scenicSpotContext: ScenicSpotContext, + appRouter: AppRouter, + toastCenter: ToastCenter + ) { + try? tokenStore.clear() + snapshotStore.clear() + accountContext.reset() + permissionContext.reset() + scenicSpotContext.reset() + appRouter.reset() + toastCenter.dismiss() + appSession.logout() + } + + /// 保存当前账号上下文的快照。 + private func saveSnapshot( + profile: AccountProfile?, + accountType: String?, + businessUserId: Int?, + permissionContext: PermissionContext? = nil, + currentRoleId: Int? = nil, + accountContext: AccountContext + ) { + snapshotStore.save( + AccountSnapshot( + profile: profile, + accountType: accountType, + businessUserId: businessUserId, + currentRoleId: permissionContext?.currentRole?.id ?? currentRoleId, + scenicScopes: accountContext.scenicScopes, + storeScopes: accountContext.storeScopes, + currentScenicId: accountContext.currentScenic?.id, + currentStoreId: accountContext.currentStore?.id + ) + ) + } + + /// 将用户资料接口响应转换为全局账号资料。 + private func makeProfile(from userInfo: UserInfoResponse, fallback: AccountProfile?) -> AccountProfile { + AccountProfile( + userId: fallback?.userId ?? "", + displayName: nonEmpty(userInfo.nickname) ?? nonEmpty(userInfo.realName) ?? fallback?.displayName ?? "未设置昵称", + phone: nonEmpty(userInfo.phone) ?? fallback?.phone, + avatarURL: nonEmpty(userInfo.avatar) ?? fallback?.avatarURL + ) + } + + /// 去除空白字符后返回非空字符串。 + private func nonEmpty(_ value: String?) -> String? { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } +} + +/// 登录页偏好实体,表示可安全恢复到表单的非敏感信息。 +struct LoginPreferences: Equatable { + let lastUsername: String? + let privacyAgreementAccepted: Bool +} + +private extension V9AuthResponse { + /// 当前账号身份实体,表示登录后实际选中的业务账号。 + struct CurrentAccountIdentity { + let accountType: String + let businessUserId: Int + } + + /// 提取当前账号身份,优先使用后端标记的 isCurrent 账号。 + var currentAccountIdentity: CurrentAccountIdentity? { + if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first { + return CurrentAccountIdentity( + accountType: storeUser.accountType, + businessUserId: storeUser.businessUserId + ) + } + if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first { + return CurrentAccountIdentity( + accountType: scenicUser.accountType, + businessUserId: scenicUser.businessUserId + ) + } + return nil + } +} diff --git a/suixinkan_ios/App/State/PermissionContext.swift b/suixinkan_ios/App/State/PermissionContext.swift new file mode 100644 index 0000000..9ae2f5b --- /dev/null +++ b/suixinkan_ios/App/State/PermissionContext.swift @@ -0,0 +1,92 @@ +// +// PermissionContext.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 权限上下文状态中心,保存角色权限、当前角色和扁平化权限 URI。 +final class PermissionContext { + + var onChange: (() -> Void)? + private(set) var rolePermissions: [RolePermissionResponse] = [] { didSet { onChange?() } } + private(set) var permissionURIs: Set = [] { didSet { onChange?() } } + var currentRole: RoleInfo? { didSet { onChange?() } } + + /// 替换角色权限列表,并尽量按缓存角色 ID 保持当前角色。 + func replaceRolePermissions(_ rolePermissions: [RolePermissionResponse], currentRoleId: Int? = nil) { + self.rolePermissions = rolePermissions + currentRole = currentRoleId.flatMap { id in + rolePermissions.first { $0.role.id == id }?.role + } ?? currentRole.flatMap { role in + rolePermissions.first { $0.role.id == role.id }?.role + } ?? rolePermissions.first?.role + permissionURIs = Set(Self.flattenPermissions(currentRole?.permission ?? [])) + } + + /// 切换当前角色,并同步账号上下文中的景区和门店选择。 + func selectRole(id roleId: Int, accountContext: AccountContext) { + guard let rolePermission = rolePermissions.first(where: { $0.role.id == roleId }) else { return } + currentRole = rolePermission.role + permissionURIs = Set(Self.flattenPermissions(rolePermission.role.permission)) + + let scenicScopes = Self.uniqueScenicScopes(from: rolePermission.scenic) + accountContext.replaceScopes( + scenic: scenicScopes, + stores: accountContext.storeScopes, + currentScenicId: accountContext.currentScenic?.id, + currentStoreId: accountContext.currentStore?.id + ) + } + + /// 判断当前角色是否拥有指定 URI 权限。 + func canAccess(_ uri: String) -> Bool { + permissionURIs.contains(uri.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + /// 返回当前角色关联的景区作用域。 + func currentRoleScenicScopes() -> [BusinessScope] { + guard let currentRole else { return [] } + guard let rolePermission = rolePermissions.first(where: { $0.role.id == currentRole.id }) else { + return [] + } + return Self.uniqueScenicScopes(from: rolePermission.scenic) + } + + /// 清空权限上下文,通常在退出登录时调用。 + func reset() { + rolePermissions = [] + permissionURIs = [] + currentRole = nil + } + + /// 递归提取权限树里的非空 URI。 + private static func flattenPermissions(_ permissions: [PermissionItem]) -> [String] { + permissions.flatMap { item -> [String] in + let current = item.uri.trimmingCharacters(in: .whitespacesAndNewlines) + let children = flattenPermissions(item.children) + return current.isEmpty ? children : [current] + children + } + } + + /// 将角色关联景区去重并转换为业务作用域。 + private static func uniqueScenicScopes(from scenics: [ScenicInfo]) -> [BusinessScope] { + var seen = Set() + return scenics.compactMap { scenic in + guard seen.insert(scenic.id).inserted else { return nil } + return BusinessScope( + id: scenic.id, + name: scenic.name, + kind: .scenic, + status: scenic.status, + address: scenic.location?.address, + latitude: scenic.location?.lat, + longitude: scenic.location?.lng, + coverURLString: scenic.coverImg + ) + } + } +} diff --git a/suixinkan_ios/App/State/ScenicSpotContext.swift b/suixinkan_ios/App/State/ScenicSpotContext.swift new file mode 100644 index 0000000..14b8c08 --- /dev/null +++ b/suixinkan_ios/App/State/ScenicSpotContext.swift @@ -0,0 +1,60 @@ +// +// ScenicSpotContext.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 景点加载阶段实体,表示当前景区景点列表的加载状态。 +enum ScenicSpotLoadState: Equatable { + case idle + case loading + case loaded + case failed(String) +} + +@MainActor +/// 景点上下文状态中心,保存当前景区下的景点或打卡点列表。 +final class ScenicSpotContext { + + var onChange: (() -> Void)? + private(set) var scenicId: Int? { didSet { onChange?() } } + private(set) var spots: [ScenicSpotItem] = [] { didSet { onChange?() } } + private(set) var loadState: ScenicSpotLoadState = .idle { didSet { onChange?() } } + + /// 按景区 ID 重新加载景点列表,失败只影响景点模块本身。 + func reload(scenicId: Int?, api: AccountContextServing) async { + guard let scenicId else { + reset() + return + } + + if self.scenicId != scenicId { + spots = [] + } + self.scenicId = scenicId + loadState = .loading + + do { + let response = try await api.scenicSpotListAll(scenicId: scenicId) + guard !Task.isCancelled else { return } + spots = response.list + loadState = .loaded + } catch is CancellationError { + loadState = .idle + } catch { + guard !Task.isCancelled else { return } + spots = [] + loadState = .failed(error.localizedDescription) + } + } + + /// 清空景点上下文,通常在退出登录或没有当前景区时调用。 + func reset() { + scenicId = nil + spots = [] + loadState = .idle + } +} diff --git a/suixinkan_ios/App/State/SessionBootstrapper.swift b/suixinkan_ios/App/State/SessionBootstrapper.swift new file mode 100644 index 0000000..89e06f0 --- /dev/null +++ b/suixinkan_ios/App/State/SessionBootstrapper.swift @@ -0,0 +1,111 @@ +// +// SessionBootstrapper.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation + +@MainActor +/// 登录态启动恢复器,负责冷启动时从缓存恢复并校验 token。 +final class SessionBootstrapper { + private let tokenStore: SessionTokenStore + private let snapshotStore: AccountSnapshotStore + private var didAttemptRestore = false + + /// 初始化启动恢复器,并注入 token 与账号快照存储。 + init( + tokenStore: SessionTokenStore, + snapshotStore: AccountSnapshotStore + ) { + self.tokenStore = tokenStore + self.snapshotStore = snapshotStore + } + + /// 创建使用默认存储服务的启动恢复器。 + convenience init() { + self.init( + tokenStore: SessionTokenStore(), + snapshotStore: AccountSnapshotStore() + ) + } + + /// 从本地缓存恢复登录态并请求服务端校验。 + func restore( + appSession: AppSession, + accountContext: AccountContext, + permissionContext: PermissionContext, + profileAPI: UserProfileServing, + accountContextAPI: AccountContextServing, + toastCenter: ToastCenter + ) async { + guard !didAttemptRestore, !appSession.isLoggedIn else { return } + didAttemptRestore = true + + guard let token = tokenStore.load() else { + appSession.logout() + return + } + + appSession.beginRestoring(token: token) + let cachedSnapshot = restoreCachedSnapshot(to: accountContext) + + do { + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: profileAPI, + accountContextAPI: accountContextAPI, + cachedCurrentRoleId: cachedSnapshot?.currentRoleId, + cachedCurrentScenicId: cachedSnapshot?.currentScenicId, + cachedCurrentStoreId: cachedSnapshot?.currentStoreId + ) + saveLatestSnapshot(from: accountContext, permissionContext: permissionContext) + appSession.markLoggedIn(token: token) + } catch { + if APIError.isAuthenticationExpired(error) { + try? tokenStore.clear() + snapshotStore.clear() + accountContext.reset() + permissionContext.reset() + appSession.logout() + toastCenter.show("登录状态已失效,请重新登录") + } else { + appSession.markLoggedIn(token: token) + toastCenter.show("网络异常,已使用本地登录状态") + } + } + } + + /// 将缓存快照恢复到账号上下文。 + private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? { + guard let snapshot = snapshotStore.load() else { return nil } + accountContext.applyLogin(profile: snapshot.profile) + accountContext.replaceScopes( + scenic: snapshot.scenicScopes, + stores: snapshot.storeScopes, + currentScenicId: snapshot.currentScenicId, + currentStoreId: snapshot.currentStoreId + ) + return snapshot + } + + /// 保存服务端校验后的最新账号上下文。 + private func saveLatestSnapshot(from accountContext: AccountContext, permissionContext: PermissionContext) { + let existing = snapshotStore.load() + snapshotStore.save( + AccountSnapshot( + profile: accountContext.profile, + accountType: existing?.accountType, + businessUserId: existing?.businessUserId, + currentRoleId: permissionContext.currentRole?.id ?? existing?.currentRoleId, + scenicScopes: accountContext.scenicScopes, + storeScopes: accountContext.storeScopes, + currentScenicId: accountContext.currentScenic?.id, + currentStoreId: accountContext.currentStore?.id + ) + ) + } + +} diff --git a/suixinkan_ios/App/State/ToastCenter.swift b/suixinkan_ios/App/State/ToastCenter.swift new file mode 100644 index 0000000..f938d7c --- /dev/null +++ b/suixinkan_ios/App/State/ToastCenter.swift @@ -0,0 +1,182 @@ +// +// ToastCenter.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import UIKit + +@MainActor +/// 全局 Toast 状态中心,负责保存和清除当前提示文案。 +final class ToastCenter { + var onChange: (() -> Void)? + private(set) var message: String? { didSet { onChange?() } } + + private let autoDismissNanoseconds: UInt64 + private var dismissTask: Task? + private var displayToken: UInt64 = 0 + private weak var overlayView: ToastOverlayView? + + /// 创建 Toast 状态中心,默认 2.2 秒后自动隐藏。 + init(autoDismissNanoseconds: UInt64 = 2_200_000_000) { + self.autoDismissNanoseconds = autoDismissNanoseconds + } + + /// 显示一条全局 Toast 文案。 + func show(_ message: String) { + displayToken &+= 1 + self.message = message + scheduleAutoDismiss(token: displayToken) + } + + /// 清除当前 Toast 文案。 + func dismiss() { + displayToken &+= 1 + dismissTask?.cancel() + dismissTask = nil + message = nil + } + + /// 将全局 Toast 展示层挂载到指定窗口,通常只在应用根部调用一次。 + func attachToWindow(_ window: UIWindow) { + if let overlayView, overlayView.superview === window { + return + } + + let overlay = ToastOverlayView(center: self) + overlay.frame = window.bounds + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + window.addSubview(overlay) + overlayView = overlay + } + + /// 为当前 Toast 安排自动隐藏任务,并避免旧任务误清除新 Toast。 + private func scheduleAutoDismiss(token: UInt64) { + dismissTask?.cancel() + let delay = autoDismissNanoseconds + dismissTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: delay) + } catch { + return + } + await MainActor.run { + guard let self, self.displayToken == token else { return } + self.message = nil + self.dismissTask = nil + } + } + } + + #if DEBUG + /// 测试专用快照,业务代码不应读取 Toast 展示状态。 + var snapshotForTests: ToastSnapshot { + ToastSnapshot(message: message) + } + #endif +} + +#if DEBUG +/// 全局 Toast 测试快照,用于验证文案和自动隐藏行为。 +struct ToastSnapshot: Equatable { + let message: String? +} +#endif + +/// 全局 Toast 展示层,负责把 Toast 展示到窗口顶部。 +final class ToastOverlayView: UIView { + private weak var toastCenter: ToastCenter? + private let bannerView = UIView() + private let messageLabel = UILabel() + + /// 使用 Toast 状态中心初始化展示层。 + init(center: ToastCenter) { + self.toastCenter = center + super.init(frame: .zero) + isUserInteractionEnabled = false + configureViews() + center.onChange = { [weak self] in + self?.refreshPresentation(animated: true) + } + refreshPresentation(animated: false) + } + + required init?(coder: NSCoder) { + nil + } + + private func configureViews() { + bannerView.backgroundColor = AppDesign.primary + bannerView.isHidden = true + bannerView.alpha = 0 + + messageLabel.font = .systemFont(ofSize: 14, weight: .medium) + messageLabel.textColor = .white + messageLabel.textAlignment = .center + messageLabel.numberOfLines = 2 + + bannerView.addSubview(messageLabel) + addSubview(bannerView) + + bannerView.translatesAutoresizingMaskIntoConstraints = false + messageLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + bannerView.leadingAnchor.constraint(equalTo: leadingAnchor), + bannerView.trailingAnchor.constraint(equalTo: trailingAnchor), + bannerView.topAnchor.constraint(equalTo: topAnchor), + + messageLabel.leadingAnchor.constraint(equalTo: bannerView.leadingAnchor, constant: AppMetrics.Spacing.pageHorizontal), + messageLabel.trailingAnchor.constraint(equalTo: bannerView.trailingAnchor, constant: -AppMetrics.Spacing.pageHorizontal), + messageLabel.topAnchor.constraint(equalTo: bannerView.safeAreaLayoutGuide.topAnchor, constant: AppMetrics.Spacing.small), + messageLabel.bottomAnchor.constraint(equalTo: bannerView.bottomAnchor, constant: -AppMetrics.Spacing.small) + ]) + } + + private func refreshPresentation(animated: Bool) { + guard let message = toastCenter?.message, !message.isEmpty else { + hideBanner(animated: animated) + return + } + + messageLabel.text = message + showBanner(animated: animated) + } + + private func showBanner(animated: Bool) { + guard bannerView.isHidden || bannerView.alpha < 1 else { return } + + bannerView.isHidden = false + guard animated else { + bannerView.alpha = 1 + bannerView.transform = .identity + return + } + + bannerView.alpha = 0 + bannerView.transform = CGAffineTransform(translationX: 0, y: -12) + UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut]) { + self.bannerView.alpha = 1 + self.bannerView.transform = .identity + } + } + + private func hideBanner(animated: Bool) { + guard !bannerView.isHidden else { return } + + guard animated else { + bannerView.isHidden = true + bannerView.alpha = 0 + return + } + + UIView.animate(withDuration: 0.18, delay: 0, options: [.curveEaseInOut], animations: { + self.bannerView.alpha = 0 + self.bannerView.transform = CGAffineTransform(translationX: 0, y: -12) + }, completion: { _ in + self.bannerView.isHidden = true + self.bannerView.transform = .identity + }) + } +} diff --git a/suixinkan_ios/App/ViewControllers/RootViewController.swift b/suixinkan_ios/App/ViewControllers/RootViewController.swift new file mode 100644 index 0000000..dfd120b --- /dev/null +++ b/suixinkan_ios/App/ViewControllers/RootViewController.swift @@ -0,0 +1,138 @@ +// +// RootViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +@MainActor +/// 应用根 ViewController,根据登录阶段切换子页面并挂载全局 Overlay。 +final class RootViewController: UIViewController { + + private let services: AppServices + private var currentChild: UIViewController? + private let restoringView = UIView() + + init(services: AppServices = .shared) { + self.services = services + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + restoringView.backgroundColor = .systemBackground + + services.appSession.onChange = { [weak self] in + self?.handleSessionPhaseChange() + } + + mountChild(for: services.appSession.phase) + bootstrapSession() + configurePushNotifications() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + attachGlobalOverlays() + } + + private func bootstrapSession() { + Task { + await services.globalLoading.withLoading { + await services.sessionBootstrapper.restore( + appSession: services.appSession, + accountContext: services.accountContext, + permissionContext: services.permissionContext, + profileAPI: services.profileAPI, + accountContextAPI: services.accountContextAPI, + toastCenter: services.toastCenter + ) + } + + if services.appSession.isLoggedIn, !AppUITestLaunchState.isRunningUITests { + PushNotificationManager.shared.requestAuthorizationAndRegister() + } + } + } + + private func configurePushNotifications() { + services.configurePushNotifications() + } + + private func attachGlobalOverlays() { + guard let window = view.window else { return } + services.toastCenter.attachToWindow(window) + services.globalLoading.attachToWindow(window) + } + + private func handleSessionPhaseChange() { + mountChild(for: services.appSession.phase) + + switch services.appSession.phase { + case .loggedIn: + if !AppUITestLaunchState.isRunningUITests { + PushNotificationManager.shared.requestAuthorizationAndRegister() + } + Task { await reloadScenicSpotContextIfNeeded() } + case .loggedOut: + services.accountContext.reset() + services.permissionContext.reset() + services.scenicSpotContext.reset() + services.appRouter.reset() + services.toastCenter.dismiss() + services.scenicQueueRuntime.stop() + case .restoring: + break + } + } + + private func reloadScenicSpotContextIfNeeded() async { + guard services.appSession.isLoggedIn else { + services.scenicSpotContext.reset() + return + } + await services.scenicSpotContext.reload( + scenicId: services.accountContext.currentScenic?.id, + api: services.accountContextAPI + ) + } + + private func mountChild(for phase: AuthPhase) { + let nextChild: UIViewController + switch phase { + case .loggedOut: + nextChild = LoginViewController(services: services) + case .restoring: + nextChild = restoringViewController() + case .loggedIn: + nextChild = MainTabBarController(services: services) + } + + guard currentChild !== nextChild else { return } + + currentChild?.willMove(toParent: nil) + currentChild?.view.removeFromSuperview() + currentChild?.removeFromParent() + + addChild(nextChild) + view.addSubview(nextChild.view) + nextChild.view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + nextChild.didMove(toParent: self) + currentChild = nextChild + } + + private func restoringViewController() -> UIViewController { + let controller = UIViewController() + controller.view.backgroundColor = .systemBackground + return controller + } +} diff --git a/suixinkan_ios/AppDelegate.swift b/suixinkan_ios/AppDelegate.swift new file mode 100644 index 0000000..b232f66 --- /dev/null +++ b/suixinkan_ios/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// suixinkan_ios +// +// Created by hanqiu on 2026/6/26. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/suixinkan_ios/Assets.xcassets/AccentColor.colorset/Contents.json b/suixinkan_ios/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Assets.xcassets/Contents.json b/suixinkan_ios/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/suixinkan_ios/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan_ios/Base.lproj/LaunchScreen.storyboard b/suixinkan_ios/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/suixinkan_ios/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/suixinkan_ios/Core/Core.md b/suixinkan_ios/Core/Core.md new file mode 100644 index 0000000..fe983cf --- /dev/null +++ b/suixinkan_ios/Core/Core.md @@ -0,0 +1,93 @@ +# Core 模块业务逻辑 + +## 模块职责 + +Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存储和通用设计常量。业务页面不应直接重复实现这些能力。 + +主要子模块: +- `Networking`:统一 API 请求、响应解析、错误处理和 token 注入。 +- `Storage`:统一登录 token、账号快照和 App 偏好的本地存储。 +- `Design`:统一颜色、字号、间距、控件尺寸和圆角。 +- `Upload`:统一阿里云 OSS 上传、图片压缩、上传策略和 Kingfisher 网络图片展示。 + +## Networking + +`APIClient` 是统一网络客户端,负责: +- 根据 `APIRequest` 生成 `URLRequest`。 +- 注入公共 Header:`Content-Type`、`Accept`、App 版本和系统类型。 +- 通过 token provider 或 `tokenOverride` 注入登录 token。 +- 发送请求并处理 URLSession 错误。 +- 校验 HTTP 状态码。 +- 解码后端统一 `APIEnvelope`。 +- 将 HTTP 错误、业务错误、解析错误转成 `APIError`。 + +业务模块只应该封装自己的 API 类,例如 `AuthAPI`、`ProfileAPI`,然后调用 `APIClient.send`。页面和 ViewModel 不应直接拼接 URL 或处理原始响应体。 + +### 响应约定 + +后端响应通过 `APIEnvelope` 解包: +- `code` 表示业务状态。 +- `msg` 表示业务提示。 +- `data` 是真正业务数据。 + +`APIEnvelope.isSuccess` 为 false 时,`APIClient` 抛出 `APIError.serverCode`。 + +### token 失效判断 + +`APIError.isAuthenticationExpired` 用于判断是否需要清空登录态: +- HTTP 401 / 403 视为登录失效。 +- 业务码 `200001` 视为登录失效。 +- 错误文案包含 token、过期、登录失效、重新登录、unauthorized、验证失败等关键词时视为登录失效。 + +## Storage + +本地缓存按安全级别拆分: +- `SessionTokenStore`:使用 Keychain 保存正式 token。 +- `AccountSnapshotStore`:使用 UserDefaults 保存非敏感账号快照。 +- `AppPreferencesStore`:使用 UserDefaults 保存上次手机号、协议同意状态等偏好。 + +缓存边界: +- 正式 token 只放 Keychain。 +- 临时 token 只放内存。 +- 密码、验证码、OSS STS token、一次性扫码结果和错误提示不落盘。 +- 头像、证件照等图片缓存交给 Kingfisher,Core 不保存图片 Data。 + +`AccountSnapshot` 保存可重建的账号展示和业务上下文: +- `AccountProfile` +- 账号类型和业务账号 ID +- 当前角色 ID +- 景区作用域和门店作用域 +- 当前景区 ID 和当前门店 ID + +## Design + +`AppDesign` 管理跨页面颜色。`AppMetrics` 管理常用字号、间距、控件尺寸、行距和圆角。 + +新增页面时优先使用 `AppMetrics` 和 `AppDesign`。只有明显属于单个页面的特殊尺寸,才保留在页面本地。 + +### 全局 Loading + +`GlobalLoadingCenter` 是全局 Loading 的命令中心,只负责展示加载状态,不保存业务数据。业务 View 只能通过 Environment 获取它并调用 `show`、`hide`、`updateMessage`、`withLoading` 或 `withOptionalLoading`,不要在 `body` 中读取 `isVisible`、`message` 等展示状态。 + +Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay,不会让当前页面、Tab 根视图或业务子视图形成观察依赖。 + +当前全局 Loading UI 不展示文案;`message` 字段保留为内部展示状态,便于后续需要时恢复文案展示。 + +全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。 + +### 全局 Toast + +`ToastCenter` 只用于展示轻量提示文案,业务页面通过 `toastCenter.show(...)` 发出提示命令,不直接读取 Toast 展示状态。 + +Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部安全区和屏幕左右边;文案居中展示,无关闭按钮,默认 2.2 秒自动隐藏。新的 Toast 会覆盖旧 Toast 并重新计时。 + +## Upload + +`UploadAPI` 通过 `/api/app/config/get-sts-token` 获取阿里云 OSS 临时上传配置。`OSSUploadService` 负责校验文件、生成 objectKey、调用 `AlibabaCloudOSS` SDK 并返回最终文件 URL。 + +上传模块只保存内存状态: +- STS token 不写入 Keychain 或 UserDefaults。 +- 用户选择的本地图片 Data 不落盘。 +- 上传进度只用于当前页面展示。 + +网络图片统一使用 `RemoteImage` / `RemoteAvatarImage`,内部由 Kingfisher 负责下载和缓存。业务页面不要再直接使用 `AsyncImage` 加载网络图片。 diff --git a/suixinkan_ios/Core/Design/AppContentUnavailableView.swift b/suixinkan_ios/Core/Design/AppContentUnavailableView.swift new file mode 100644 index 0000000..0c76646 --- /dev/null +++ b/suixinkan_ios/Core/Design/AppContentUnavailableView.swift @@ -0,0 +1,111 @@ +// +// AppContentUnavailableView.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import SnapKit +import UIKit + +/// iOS 16 兼容的空状态视图,用于列表或页面无数据时的占位展示。 +final class AppContentUnavailableView: UIView { + private let contentStack = UIStackView() + private let labelStack = UIStackView() + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + private let actionsContainer = UIStackView() + + /// 使用标题、系统图标和可选描述、操作区构建空状态视图。 + init( + title: String, + systemImage: String, + description: String? = nil, + actions: [UIView] = [] + ) { + super.init(frame: .zero) + configureViews(title: title, systemImage: systemImage, description: description, actions: actions) + } + + required init?(coder: NSCoder) { + nil + } + + /// 更新空状态文案和图标。 + func update(title: String, systemImage: String, description: String? = nil) { + titleLabel.text = title + iconView.image = UIImage(systemName: systemImage) + descriptionLabel.text = description + descriptionLabel.isHidden = description?.isEmpty != false + } + + /// 替换操作区子视图。 + func setActions(_ actions: [UIView]) { + actionsContainer.arrangedSubviews.forEach { view in + actionsContainer.removeArrangedSubview(view) + view.removeFromSuperview() + } + actions.forEach { actionsContainer.addArrangedSubview($0) } + actionsContainer.isHidden = actions.isEmpty + } + + private func configureViews( + title: String, + systemImage: String, + description: String?, + actions: [UIView] + ) { + contentStack.axis = .vertical + contentStack.alignment = .center + contentStack.spacing = AppMetrics.Spacing.small + + labelStack.axis = .vertical + labelStack.alignment = .center + labelStack.spacing = AppMetrics.Spacing.xSmall + + iconView.image = UIImage(systemName: systemImage) + iconView.tintColor = AppDesign.textSecondary + iconView.contentMode = .scaleAspectFit + iconView.setContentHuggingPriority(.required, for: .vertical) + + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold) + titleLabel.textColor = AppDesign.textPrimary + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + + descriptionLabel.text = description + descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + descriptionLabel.textColor = AppDesign.textSecondary + descriptionLabel.textAlignment = .center + descriptionLabel.numberOfLines = 0 + descriptionLabel.isHidden = description?.isEmpty != false + + actionsContainer.axis = .vertical + actionsContainer.alignment = .center + actionsContainer.spacing = AppMetrics.Spacing.xSmall + actionsContainer.isHidden = actions.isEmpty + actions.forEach { actionsContainer.addArrangedSubview($0) } + + labelStack.addArrangedSubview(iconView) + labelStack.addArrangedSubview(titleLabel) + if description?.isEmpty == false { + labelStack.addArrangedSubview(descriptionLabel) + } + + contentStack.addArrangedSubview(labelStack) + if !actions.isEmpty { + contentStack.addArrangedSubview(actionsContainer) + } + + addSubview(contentStack) + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppMetrics.Spacing.large) + } + + iconView.snp.makeConstraints { make in + make.width.height.equalTo(44) + } + } +} diff --git a/suixinkan_ios/Core/Design/AppDesign.swift b/suixinkan_ios/Core/Design/AppDesign.swift new file mode 100644 index 0000000..eb15f36 --- /dev/null +++ b/suixinkan_ios/Core/Design/AppDesign.swift @@ -0,0 +1,21 @@ +// +// AppDesign.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import UIKit + +/// 应用通用颜色定义,集中管理跨页面复用的设计色值。 +enum AppDesign { + static let primary = UIColor(hex: 0x0073FF) + static let primarySoft = UIColor(hex: 0xEFF6FF) + static let textPrimary = UIColor(hex: 0x1F2937) + static let textSecondary = UIColor(hex: 0x6B7280) + static let placeholder = UIColor(hex: 0xA8B2C1) + static let success = UIColor(hex: 0x14964A) + static let warning = UIColor(hex: 0xFF7B00) + static let inputBackground = UIColor(hex: 0xF8FAFC) + static let inputBorder = UIColor(hex: 0xE2E8F0) +} diff --git a/suixinkan_ios/Core/Design/AppMetrics.swift b/suixinkan_ios/Core/Design/AppMetrics.swift new file mode 100644 index 0000000..213b27f --- /dev/null +++ b/suixinkan_ios/Core/Design/AppMetrics.swift @@ -0,0 +1,68 @@ +// +// 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 substantial: CGFloat = 14 + static let medium: CGFloat = 16 + static let mediumLarge: CGFloat = 18 + static let large: CGFloat = 20 + static let sheet: CGFloat = 22 + static let xLarge: CGFloat = 24 + static let xxLarge: CGFloat = 30 + static let pageHorizontal: CGFloat = 16 + static let pageVertical: CGFloat = 24 + } + + /// 控件尺寸实体,统一维护按钮、输入框和图标点击区尺寸。 + enum ControlSize { + static let smallIcon: CGFloat = 16 + static let checkboxIcon: CGFloat = 20 + static let passwordIcon: CGFloat = 22 + static let progressWidth: CGFloat = 22 + static let checkboxTapArea: CGFloat = 28 + static let iconTapArea: CGFloat = 32 + static let sheetButtonHeight: CGFloat = 48 + static let primaryButtonHeight: CGFloat = 50 + static let inputHeight: CGFloat = 52 + } + + /// 行距实体,统一维护多行文本的常用行间距。 + enum LineSpacing { + static let title: CGFloat = 3 + } + + /// 圆角尺寸实体,统一维护输入框、按钮和卡片圆角。 + enum CornerRadius { + static let input: CGFloat = 12 + static let button: CGFloat = 12 + static let card: CGFloat = 16 + } +} diff --git a/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift b/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift new file mode 100644 index 0000000..a72891f --- /dev/null +++ b/suixinkan_ios/Core/Design/GlobalLoadingCenter.swift @@ -0,0 +1,266 @@ +// +// GlobalLoadingCenter.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation +import UIKit +#if canImport(Lottie) +import Lottie +#endif + +@MainActor +/// 全局 Loading 命令中心,只负责展示状态控制,不承载业务数据。 +final class GlobalLoadingCenter { + fileprivate let state = GlobalLoadingState() + private weak var overlayView: GlobalLoadingOverlayView? + + /// 显示全局 Loading;多次调用会通过引用计数叠加。 + func show(message: String = "") { + if !message.isEmpty { + state.message = message + } + state.activeCount += 1 + state.isVisible = true + } + + /// 隐藏一次全局 Loading;所有调用方都结束后才真正关闭。 + func hide() { + state.activeCount = max(0, state.activeCount - 1) + guard state.activeCount == 0 else { return } + state.isVisible = false + state.message = "" + } + + /// 更新当前 Loading 文案,仅在 Loading 可见且文案非空时生效。 + func updateMessage(_ message: String) { + guard state.isVisible, !message.isEmpty else { return } + state.message = message + } + + /// 包裹一个异步操作,操作结束或抛错时自动关闭 Loading。 + func withLoading( + message: String = "", + operation: () async throws -> T + ) async rethrows -> T { + show(message: message) + defer { hide() } + return try await operation() + } + + /// 根据开关决定是否展示 Loading,适合下拉刷新等可选 loading 场景。 + func withOptionalLoading( + _ enabled: Bool, + message: String = "", + operation: () async throws -> T + ) async rethrows -> T { + if enabled { + return try await withLoading(message: message, operation: operation) + } + return try await operation() + } + + /// 将全局 Loading 展示层挂载到指定窗口,通常只在应用根部调用一次。 + func attachToWindow(_ window: UIWindow) { + if let overlayView, overlayView.superview === window { + return + } + + let overlay = GlobalLoadingOverlayView(state: state) + overlay.frame = window.bounds + overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight] + window.addSubview(overlay) + overlayView = overlay + } + + #if DEBUG + /// 测试专用快照,业务代码不应读取 Loading 展示状态。 + var snapshotForTests: GlobalLoadingSnapshot { + GlobalLoadingSnapshot( + isVisible: state.isVisible, + message: state.message, + activeCount: state.activeCount + ) + } + #endif +} + +@MainActor +/// 全局 Loading 的私有状态,只允许根部 Overlay 订阅。 +final class GlobalLoadingState { + var onChange: (() -> Void)? + fileprivate var isVisible = false { didSet { onChange?() } } + fileprivate var message = "" { didSet { onChange?() } } + fileprivate var activeCount = 0 { didSet { onChange?() } } +} + +/// 全局 Loading 展示层,订阅 Loading 状态并覆盖在窗口最上层。 +fileprivate final class GlobalLoadingOverlayView: UIView { + private let state: GlobalLoadingState + private let dimmingView = UIView() + private let cardView = UIView() + private let animationContainer = UIView() + private var animationView: UIView? + + /// 使用全局 Loading 状态初始化展示层。 + init(state: GlobalLoadingState) { + self.state = state + super.init(frame: .zero) + configureViews() + state.onChange = { [weak self] in + self?.refreshPresentation(animated: true) + } + refreshPresentation(animated: false) + } + + required init?(coder: NSCoder) { + nil + } + + private func configureViews() { + isUserInteractionEnabled = true + accessibilityLabel = "加载中" + + dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.28) + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 18 + cardView.layer.cornerCurve = .continuous + cardView.layer.shadowColor = UIColor.black.cgColor + cardView.layer.shadowOpacity = 0.12 + cardView.layer.shadowRadius = 24 + cardView.layer.shadowOffset = CGSize(width: 0, height: 10) + + animationView = Self.makeAnimationView() + if let animationView { + animationContainer.addSubview(animationView) + animationView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + animationView.leadingAnchor.constraint(equalTo: animationContainer.leadingAnchor), + animationView.trailingAnchor.constraint(equalTo: animationContainer.trailingAnchor), + animationView.topAnchor.constraint(equalTo: animationContainer.topAnchor), + animationView.bottomAnchor.constraint(equalTo: animationContainer.bottomAnchor) + ]) + } + + addSubview(dimmingView) + addSubview(cardView) + cardView.addSubview(animationContainer) + + dimmingView.translatesAutoresizingMaskIntoConstraints = false + cardView.translatesAutoresizingMaskIntoConstraints = false + animationContainer.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + dimmingView.leadingAnchor.constraint(equalTo: leadingAnchor), + dimmingView.trailingAnchor.constraint(equalTo: trailingAnchor), + dimmingView.topAnchor.constraint(equalTo: topAnchor), + dimmingView.bottomAnchor.constraint(equalTo: bottomAnchor), + + cardView.centerXAnchor.constraint(equalTo: centerXAnchor), + cardView.centerYAnchor.constraint(equalTo: centerYAnchor), + + animationContainer.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 10), + animationContainer.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -10), + animationContainer.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 10), + animationContainer.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -10), + animationContainer.widthAnchor.constraint(equalToConstant: 132), + animationContainer.heightAnchor.constraint(equalToConstant: 132) + ]) + } + + private func refreshPresentation(animated: Bool) { + let shouldShow = state.isVisible + let updates = { + self.isHidden = !shouldShow + self.alpha = shouldShow ? 1 : 0 + } + + guard animated else { + updates() + return + } + + if shouldShow { + isHidden = false + alpha = 0 + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut]) { + self.alpha = 1 + } + } else { + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: { + self.alpha = 0 + }, completion: { _ in + self.isHidden = true + }) + } + } + + /// 判断当前包内是否存在可用 Loading 动画资源。 + private static var hasAnimationResource: Bool { + loadAnimation() != nil + } + + /// 创建 Lottie 或系统菊花动画视图。 + private static func makeAnimationView() -> UIView? { + #if canImport(Lottie) + if hasAnimationResource { + let container = UIView() + container.backgroundColor = .clear + + let animationView = LottieAnimationView() + animationView.translatesAutoresizingMaskIntoConstraints = false + animationView.contentMode = .scaleAspectFit + animationView.loopMode = .loop + animationView.backgroundBehavior = .pauseAndRestore + animationView.animation = loadAnimation() + animationView.play() + + container.addSubview(animationView) + NSLayoutConstraint.activate([ + animationView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + animationView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + animationView.topAnchor.constraint(equalTo: container.topAnchor), + animationView.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + return container + } + #endif + + let indicator = UIActivityIndicatorView(style: .large) + indicator.color = AppDesign.primary + indicator.startAnimating() + return indicator + } + + /// 从主包中加载 loading 动画资源,兼容 Resources 子目录和根目录。 + #if canImport(Lottie) + private static func loadAnimation() -> LottieAnimation? { + if let animation = LottieAnimation.named("loading") { + return animation + } + if let url = Bundle.main.url(forResource: "loading", withExtension: "json", subdirectory: "Resources") { + return LottieAnimation.filepath(url.path) + } + if let url = Bundle.main.url(forResource: "loading", withExtension: "json") { + return LottieAnimation.filepath(url.path) + } + return nil + } + #else + private static func loadAnimation() -> Any? { + nil + } + #endif +} + +#if DEBUG +/// 全局 Loading 测试快照,用于验证引用计数和展示文案。 +struct GlobalLoadingSnapshot: Equatable { + let isVisible: Bool + let message: String + let activeCount: Int +} +#endif diff --git a/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift b/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift new file mode 100644 index 0000000..963cbd3 --- /dev/null +++ b/suixinkan_ios/Core/Location/ForegroundLocationProvider.swift @@ -0,0 +1,105 @@ +// +// ForegroundLocationProvider.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import CoreLocation +import Foundation + +/// 前台定位结果实体,表示一次即时定位得到的坐标和可展示地址。 +struct ForegroundLocationResult: Equatable { + let latitude: Double + let longitude: Double + let address: String +} + +/// 前台定位 Provider,只负责当前页面主动请求位置,不缓存、不后台持续定位。 +@MainActor +final class ForegroundLocationProvider: NSObject { + private let manager = CLLocationManager() + private let geocoder = CLGeocoder() + private var continuation: CheckedContinuation? + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + } + + /// 请求一次当前位置,成功后尝试反解析为地址。 + func requestCurrentLocation() async throws -> ForegroundLocationResult { + if let continuation { + continuation.resume(throwing: LocationProviderError.duplicatedRequest) + self.continuation = nil + } + let status = manager.authorizationStatus + if status == .notDetermined { + manager.requestWhenInUseAuthorization() + } + + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + manager.requestLocation() + } + } + + /// 将 CLLocation 转为页面可展示的定位结果。 + private func makeResult(from location: CLLocation) async -> ForegroundLocationResult { + let address: String + if let placemark = try? await geocoder.reverseGeocodeLocation(location).first { + address = [ + placemark.administrativeArea, + placemark.locality, + placemark.subLocality, + placemark.thoroughfare, + placemark.name + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "") + } else { + address = "当前位置" + } + return ForegroundLocationResult( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + address: address.isEmpty ? "当前位置" : address + ) + } +} + +extension ForegroundLocationProvider: CLLocationManagerDelegate { + /// 定位成功回调,返回最近一次坐标。 + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + Task { @MainActor in + guard let continuation = self.continuation else { return } + self.continuation = nil + let result = await self.makeResult(from: location) + continuation.resume(returning: result) + } + } + + /// 定位失败回调,将错误传给当前请求方。 + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + guard let continuation = self.continuation else { return } + self.continuation = nil + continuation.resume(throwing: error) + } + } +} + +/// 前台定位 Provider 错误实体。 +enum LocationProviderError: LocalizedError { + case duplicatedRequest + + var errorDescription: String? { + switch self { + case .duplicatedRequest: + "已有定位请求正在执行" + } + } +} diff --git a/suixinkan_ios/Core/Models/SharedBusinessModels.swift b/suixinkan_ios/Core/Models/SharedBusinessModels.swift new file mode 100644 index 0000000..c0096e8 --- /dev/null +++ b/suixinkan_ios/Core/Models/SharedBusinessModels.swift @@ -0,0 +1,166 @@ +// +// SharedBusinessModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 可关联订单实体,表示任务、排班等业务表单中可选择的订单。 +struct AvailableOrderResponse: Decodable, Identifiable, Hashable { + var id: String { orderNumber } + let projectName: String + let orderNumber: String + let orderStatus: Int + let orderStatusLabel: String + let payTime: String + let userPhone: String + + /// 可关联订单字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case projectName = "project_name" + case orderNumber = "order_number" + case orderStatus = "order_status" + case orderStatusLabel = "order_status_label" + case payTime = "pay_time" + case userPhone = "user_phone" + } + + /// 宽松解码可关联订单字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + projectName = try container.decodeSharedLossyString(forKey: .projectName) + orderNumber = try container.decodeSharedLossyString(forKey: .orderNumber) + orderStatus = try container.decodeSharedLossyInt(forKey: .orderStatus) ?? 0 + orderStatusLabel = try container.decodeSharedLossyString(forKey: .orderStatusLabel) + payTime = try container.decodeSharedLossyString(forKey: .payTime) + userPhone = try container.decodeSharedLossyString(forKey: .userPhone) + } +} + +/// 摄影项目列表项实体,表示项目管理列表和样片上传时可关联的项目。 +struct PhotographerProjectItem: Decodable, Identifiable, Hashable { + let id: Int + let type: Int + let typeName: String + let status: Int + let statusName: String + let name: String + let coverProject: String + let coverVideo: String + let price: String + let otPrice: String + let priceDeposit: String + let attrLabel: [String] + let label: String + + /// 创建摄影项目列表项,主要用于详情页摘要和测试替身。 + init( + id: Int, + type: Int = 0, + typeName: String = "", + status: Int = 0, + statusName: String = "", + name: String, + coverProject: String = "", + coverVideo: String = "", + price: String = "", + otPrice: String = "", + priceDeposit: String = "", + attrLabel: [String] = [], + label: String = "" + ) { + self.id = id + self.type = type + self.typeName = typeName + self.status = status + self.statusName = statusName + self.name = name + self.coverProject = coverProject + self.coverVideo = coverVideo + self.price = price + self.otPrice = otPrice + self.priceDeposit = priceDeposit + self.attrLabel = attrLabel + self.label = label + } + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case type + case typeName = "type_name" + case status + case statusName = "status_name" + case name + case coverProject = "cover_project" + case coverVideo = "cover_video" + case price + case otPrice = "ot_price" + case priceDeposit = "price_deposit" + case attrLabel = "attr_label" + case label + } + + /// 宽松解码项目列表项。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeSharedLossyInt(forKey: .id) ?? 0 + type = try container.decodeSharedLossyInt(forKey: .type) ?? 0 + typeName = try container.decodeSharedLossyString(forKey: .typeName) + status = try container.decodeSharedLossyInt(forKey: .status) ?? 0 + statusName = try container.decodeSharedLossyString(forKey: .statusName) + name = try container.decodeSharedLossyString(forKey: .name) + coverProject = try container.decodeSharedLossyString(forKey: .coverProject) + coverVideo = try container.decodeSharedLossyString(forKey: .coverVideo) + price = try container.decodeSharedLossyString(forKey: .price) + otPrice = try container.decodeSharedLossyString(forKey: .otPrice) + priceDeposit = try container.decodeSharedLossyString(forKey: .priceDeposit) + label = try container.decodeSharedLossyString(forKey: .label) + if let labels = try? container.decodeIfPresent([String].self, forKey: .attrLabel) { + attrLabel = labels + } else if let text = try? container.decodeIfPresent(String.self, forKey: .attrLabel) { + attrLabel = text + .components(separatedBy: CharacterSet(charactersIn: ",,")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } else { + attrLabel = [] + } + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeSharedLossyString(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 ? "1" : "0" + } + return "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeSharedLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(text) ?? Double(text).map(Int.init) + } + return nil + } +} diff --git a/suixinkan_ios/Core/Networking/APIClient.swift b/suixinkan_ios/Core/Networking/APIClient.swift new file mode 100644 index 0000000..81e68b5 --- /dev/null +++ b/suixinkan_ios/Core/Networking/APIClient.swift @@ -0,0 +1,255 @@ +// +// APIClient.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// URLSession 抽象协议,用于让网络客户端支持测试替身。 +protocol URLSessionProtocol { + /// 发起 URLRequest 并返回原始数据和响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: URLSessionProtocol {} + +@MainActor +/// 统一网络请求客户端,负责构造请求、注入 token、校验响应和解析业务 Envelope。 +final class APIClient { + private let session: URLSessionProtocol + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private var authTokenProvider: (() -> String?)? + + private let environment: APIEnvironment + private let appVersion: String + private let osType: String + + /// 初始化网络客户端及其编码、解码和环境配置。 + init( + environment: APIEnvironment = .current, + session: URLSessionProtocol = APIClient.defaultSession, + encoder: JSONEncoder = JSONEncoder(), + decoder: JSONDecoder = JSONDecoder(), + appVersion: String = AppClientInfo.appVersion(), + osType: String = AppClientInfo.osType + ) { + self.environment = environment + self.session = session + self.encoder = encoder + self.decoder = decoder + self.appVersion = appVersion.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "1.0.0" + self.osType = osType.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? AppClientInfo.osType + } + + nonisolated private static let defaultSession: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 12 + configuration.timeoutIntervalForResource = 20 + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration) + }() + + /// 绑定 token 提供者,让业务 API 不需要直接持有登录状态。 + func bindAuthTokenProvider(_ provider: @escaping () -> String?) { + authTokenProvider = provider + } + + /// 发送强类型 APIRequest,并返回解包后的业务数据。 + func send( + _ apiRequest: APIRequest, + tokenOverride: String? = nil + ) async throws -> Response { + let request = try makeURLRequest(apiRequest, tokenOverride: tokenOverride) + logRequest(request) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch is CancellationError { + logCancelled(for: request, reason: "CancellationError") + throw CancellationError() + } catch let error as URLError { + if error.code == .cancelled { + logCancelled(for: request, reason: "URLError.cancelled") + throw CancellationError() + } + throw APIError.networkFailed(networkErrorMessage(for: error)) + } catch { + throw APIError.networkFailed(error.localizedDescription) + } + + logResponse(for: request, response: response, data: data) + try validateHTTPResponse(response, data: data) + return try decodeEnvelope(Response.self, from: data) + } + + /// 将业务请求模型转换为 URLRequest,并注入公共 Header、token 和请求体。 + private func makeURLRequest( + _ apiRequest: APIRequest, + tokenOverride: String? + ) throws -> URLRequest { + let path = apiRequest.path.hasPrefix("/") ? apiRequest.path : "/" + apiRequest.path + guard var components = URLComponents( + string: environment.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path + ) else { + throw APIError.invalidURL + } + + if !apiRequest.queryItems.isEmpty { + components.queryItems = apiRequest.queryItems + } + + guard let url = components.url else { + throw APIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = apiRequest.method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(appVersion, forHTTPHeaderField: "X-APP-VERSION") + request.setValue(osType, forHTTPHeaderField: "X-OS-TYPE") + + apiRequest.headers.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + + let token = tokenOverride ?? authTokenProvider?() + if let token = token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { + request.setValue(token, forHTTPHeaderField: "token") + } + + if let body = apiRequest.body { + request.httpBody = try encoder.encode(body) + } + + return request + } + + /// 校验 HTTP 层响应状态码,非 2xx 时提取错误信息。 + private func validateHTTPResponse(_ response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard 200 ..< 300 ~= httpResponse.statusCode else { + throw APIError.httpStatus(httpResponse.statusCode, parseHTTPErrorMessage(data: data)) + } + } + + /// 解码后端统一 Envelope,并返回内部 data 数据。 + private func decodeEnvelope(_ responseType: Response.Type, from data: Data) throws -> Response { + let envelope: APIEnvelope + do { + envelope = try decoder.decode(APIEnvelope.self, from: data) + } catch { + throw APIError.decodeFailed(error.localizedDescription) + } + + guard envelope.isSuccess else { + throw APIError.serverCode(envelope.code, envelope.msg ?? "业务请求失败") + } + + if responseType == EmptyPayload.self { + return EmptyPayload() as! Response + } + + guard let payload = envelope.data else { + throw APIError.emptyData + } + + return payload + } + + /// 从 HTTP 错误响应中提取更适合展示给用户的错误信息。 + private func parseHTTPErrorMessage(data: Data) -> String { + if let envelope = try? decoder.decode(ErrorEnvelope.self, from: data) { + if let msg = envelope.msg?.trimmingCharacters(in: .whitespacesAndNewlines), !msg.isEmpty { + return msg + } + if let message = envelope.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { + return message + } + if let error = envelope.error?.trimmingCharacters(in: .whitespacesAndNewlines), !error.isEmpty { + return error + } + } + + if let plainText = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !plainText.isEmpty { + return plainText.count > 120 ? String(plainText.prefix(120)) + "..." : plainText + } + + return "服务端返回错误" + } + + /// 将 URLError 转换成中文网络错误提示。 + private func networkErrorMessage(for error: URLError) -> String { + switch error.code { + case .timedOut: + "请求超时,请稍后重试" + case .notConnectedToInternet: + "网络不可用,请检查网络连接" + case .networkConnectionLost: + "网络连接中断,请重试" + case .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed: + "无法连接服务器,请稍后重试" + default: + error.localizedDescription + } + } + + /// 在 Debug 环境打印请求信息。 + private func logRequest(_ request: URLRequest) { + #if DEBUG + let method = request.httpMethod ?? "REQUEST" + let url = request.url?.absoluteString ?? "" + print("[API][Request] \(method) \(url)") + #endif + } + + /// 在 Debug 环境打印响应状态和响应体。 + private func logResponse(for request: URLRequest, response: URLResponse, data: Data) { + #if DEBUG + let method = request.httpMethod ?? "REQUEST" + let url = request.url?.absoluteString ?? "" + let statusCode = (response as? HTTPURLResponse).map { String($0.statusCode) } ?? "unknown" + let body = Self.debugResponseBody(from: data) + print("[API][Response] \(method) \(url) status=\(statusCode)\n\(body)") + #endif + } + + /// 在 Debug 环境打印被取消的请求信息。 + private func logCancelled(for request: URLRequest, reason: String) { + #if DEBUG + let method = request.httpMethod ?? "REQUEST" + let url = request.url?.absoluteString ?? "" + print("[API][Cancelled] \(method) \(url) reason=\(reason)") + #endif + } + + #if DEBUG + /// 将响应体格式化为便于调试阅读的字符串。 + private static func debugResponseBody(from data: Data) -> String { + guard !data.isEmpty else { return "" } + if + let object = try? JSONSerialization.jsonObject(with: data), + let prettyData = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), + let prettyJSON = String(data: prettyData, encoding: .utf8) { + return prettyJSON + } + return String(data: data, encoding: .utf8) ?? "" + } + #endif +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan_ios/Core/Networking/APIEnvelope.swift b/suixinkan_ios/Core/Networking/APIEnvelope.swift new file mode 100644 index 0000000..1273a7f --- /dev/null +++ b/suixinkan_ios/Core/Networking/APIEnvelope.swift @@ -0,0 +1,62 @@ +// +// APIEnvelope.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 后端统一响应包裹实体,承载业务 code、msg 和真实 data。 +struct APIEnvelope: Decodable { + let data: T? + let code: Int + let msg: String? + + /// 判断后端业务 code 是否代表成功。 + var isSuccess: Bool { + code == 100000 + } + + /// 后端统一 Envelope 的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case data + case code + case msg + } + + /// 自定义解码逻辑,兼容成功响应、空 data 和 EmptyPayload。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeIfPresent(Int.self, forKey: .code) ?? 0 + msg = try container.decodeIfPresent(String.self, forKey: .msg) + + guard code == 100000 else { + data = nil + return + } + + guard container.contains(.data), try !container.decodeNil(forKey: .data) else { + data = nil + return + } + + if T.self == EmptyPayload.self { + data = EmptyPayload() as? T + return + } + + data = try container.decode(T.self, forKey: .data) + } +} + +/// 空响应实体,用于表示接口成功但不返回业务 data。 +struct EmptyPayload: Codable {} + +/// 错误响应实体,用于从 HTTP 错误体中提取服务端文案。 +struct ErrorEnvelope: Decodable { + let code: Int? + let msg: String? + let message: String? + let error: String? +} diff --git a/suixinkan_ios/Core/Networking/APIEnvironment.swift b/suixinkan_ios/Core/Networking/APIEnvironment.swift new file mode 100644 index 0000000..4f2cd15 --- /dev/null +++ b/suixinkan_ios/Core/Networking/APIEnvironment.swift @@ -0,0 +1,62 @@ +// +// APIEnvironment.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 网络环境实体,集中描述 HTTP 和 WebSocket 的服务地址。 +struct APIEnvironment: Equatable { + let baseURL: URL + let webSocketURL: URL + + nonisolated static let production = APIEnvironment( + baseURL: URL(string: "https://api.zhifly.cn")!, + webSocketURL: URL(string: "wss://api.zhifly.cn/wss")! + ) + + nonisolated static let testing = APIEnvironment( + baseURL: URL(string: "https://api-test.zhifly.cn")!, + webSocketURL: URL(string: "wss://api-test.zhifly.cn/wss")! + ) + + nonisolated static var current: APIEnvironment { + #if DEBUG + .testing + #else + .production + #endif + } +} + +/// 客户端信息工具,负责提供网络请求所需的 App 版本和系统类型。 +enum AppClientInfo { + nonisolated static let osType = "iOS" + + /// 生成后端要求的 App 版本号,版本号不足三段时用 build 号补齐。 + nonisolated static func appVersion(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String { + let version = (infoDictionary?["CFBundleShortVersionString"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty ?? "1.0.0" + let build = (infoDictionary?["CFBundleVersion"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nonEmpty + let versionParts = version.split(separator: ".", omittingEmptySubsequences: false) + + if versionParts.count >= 3 { + return version + } + if versionParts.count == 2, let build { + return "\(version).\(build)" + } + return version + } +} + +private extension String { + nonisolated var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan_ios/Core/Networking/APIError.swift b/suixinkan_ios/Core/Networking/APIError.swift new file mode 100644 index 0000000..2b96645 --- /dev/null +++ b/suixinkan_ios/Core/Networking/APIError.swift @@ -0,0 +1,61 @@ +// +// APIError.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 网络层错误实体,统一转换 URL、HTTP、业务码、解析和网络异常。 +enum APIError: Error, LocalizedError { + case invalidURL + case invalidResponse + case httpStatus(Int, String) + case serverCode(Int, String) + case emptyData + case decodeFailed(String) + case networkFailed(String) + + var errorDescription: String? { + switch self { + case .invalidURL: + "请求地址无效" + case .invalidResponse: + "服务响应异常" + case let .httpStatus(statusCode, message): + "请求失败(HTTP \(statusCode)):\(message)" + case .serverCode(_, let message): + message + case .emptyData: + "接口返回数据为空" + case .decodeFailed(let message): + "数据解析失败:\(message)" + case .networkFailed(let message): + "网络请求失败:\(message)" + } + } +} + +extension APIError { + /// 判断任意错误是否代表登录凭证失效。 + static func isAuthenticationExpired(_ error: Error) -> Bool { + guard let apiError = error as? APIError else { return false } + switch apiError { + case let .httpStatus(statusCode, _): + return statusCode == 401 || statusCode == 403 + case let .serverCode(code, message): + let text = message.lowercased() + return code == 200001 + || text.contains("token") + || text.contains("过期") + || text.contains("登录失效") + || text.contains("重新登录") + || text.contains("unauthorized") + || text.contains("验证失败") + || text.contains("驗證失敗") + default: + return false + } + } +} diff --git a/suixinkan_ios/Core/Networking/APIRequest.swift b/suixinkan_ios/Core/Networking/APIRequest.swift new file mode 100644 index 0000000..858bc3d --- /dev/null +++ b/suixinkan_ios/Core/Networking/APIRequest.swift @@ -0,0 +1,55 @@ +// +// APIRequest.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// HTTP 方法实体,限制网络层支持的请求方法。 +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} + +/// 强类型 API 请求实体,描述一次请求的方法、路径、参数、Header 和响应类型。 +struct APIRequest { + var method: HTTPMethod + var path: String + var queryItems: [URLQueryItem] + var headers: [String: String] + var body: AnyEncodable? + + /// 创建一个 API 请求,并把可编码请求体擦除为统一的 AnyEncodable。 + init( + method: HTTPMethod, + path: String, + queryItems: [URLQueryItem] = [], + headers: [String: String] = [:], + body: Body? = Optional.none + ) { + self.method = method + self.path = path + self.queryItems = queryItems + self.headers = headers + self.body = body.map(AnyEncodable.init) + } +} + +/// Encodable 类型擦除包装器,用于在 APIRequest 中保存任意请求体。 +struct AnyEncodable: Encodable { + private let encodeValue: (Encoder) throws -> Void + + /// 包装任意 Encodable 请求体。 + nonisolated init(_ value: Value) { + encodeValue = value.encode(to:) + } + + /// 将被包装的请求体编码到目标 Encoder。 + nonisolated func encode(to encoder: Encoder) throws { + try encodeValue(encoder) + } +} diff --git a/suixinkan_ios/Core/Networking/ListPayload.swift b/suixinkan_ios/Core/Networking/ListPayload.swift new file mode 100644 index 0000000..8fd2e10 --- /dev/null +++ b/suixinkan_ios/Core/Networking/ListPayload.swift @@ -0,0 +1,83 @@ +// +// ListPayload.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 列表响应实体,表示后端常见的 total + list 分页或全量列表结构。 +struct ListPayload: Decodable { + let total: Int + let list: [T] + + /// 列表响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case total + case list + } + + /// 创建列表响应,主要用于测试和本地兜底数据。 + init(total: Int, list: [T]) { + self.total = total + self.list = list + } + + /// 自定义解码,兼容 total 是字符串或缺失的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + list = try container.decodeIfPresent([T].self, forKey: .list) ?? [] + } +} + +/// 数据列表响应实体,表示后端常见的 total + data 分页结构,并兼容 list 字段。 +struct DataListPayload: Decodable { + let total: Int + let data: [T] + + /// 数据列表响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case total + case data + case list + } + + /// 创建数据列表响应,主要用于测试和本地兜底数据。 + init(total: Int, data: [T]) { + self.total = total + self.data = data + } + + /// 自定义解码,兼容 data/list 字段和 total 字符串。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + data = try container.decodeIfPresent([T].self, forKey: .data) + ?? container.decodeIfPresent([T].self, forKey: .list) + ?? [] + } +} + +private extension KeyedDecodingContainer { + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan_ios/Core/Networking/Networking.md b/suixinkan_ios/Core/Networking/Networking.md new file mode 100644 index 0000000..c35d4bc --- /dev/null +++ b/suixinkan_ios/Core/Networking/Networking.md @@ -0,0 +1,305 @@ +# Networking 模块业务逻辑 + +## 模块职责 + +Networking 模块是 App 的统一网络请求入口。业务模块不直接使用 `URLSession`,而是通过自己的 API 类创建 `APIRequest`,再交给 `APIClient.send` 发送。 + +这个模块负责: +- 维护不同环境的服务器地址。 +- 描述一次接口请求的方法、路径、query、header、body 和响应类型。 +- 把业务请求转换成 `URLRequest`。 +- 自动注入公共 Header 和登录 token。 +- 发送请求并处理网络错误。 +- 校验 HTTP 状态码。 +- 解包后端统一 Envelope。 +- 把错误转换成统一的 `APIError`。 + +## 文件职责 + +- `APIEnvironment.swift`:定义生产环境、测试环境、HTTP baseURL 和 WebSocket 地址。 +- `APIRequest.swift`:定义强类型请求模型,业务 API 通过它声明接口。 +- `APIClient.swift`:统一网络客户端,负责真正构造、发送、解析请求。 +- `APIEnvelope.swift`:定义后端统一响应结构。 +- `APIError.swift`:定义网络层错误和登录失效判断。 + +## 请求调用链路 + +一次业务请求的大致流程是: + +1. 业务 API 创建 `APIRequest`。 +2. `APIClient.send` 接收这个请求。 +3. `APIClient.makeURLRequest` 把 `APIRequest` 转成 `URLRequest`。 +4. `APIClient` 注入公共 Header、业务 Header、token 和请求体。 +5. `URLSessionProtocol.data(for:)` 发送请求。 +6. `APIClient.validateHTTPResponse` 校验 HTTP 状态码。 +7. `APIClient.decodeEnvelope` 解包后端 Envelope。 +8. 成功时返回 `Response` 类型的业务数据。 +9. 失败时抛出 `APIError`。 + +## APIRequest 的作用 + +`APIRequest` 是业务接口和网络底层之间的桥梁。 + +它包含: +- `method`:HTTP 方法,目前支持 GET、POST、PUT、DELETE。 +- `path`:接口路径,例如 `/api/app/v9/login`。 +- `queryItems`:URL query 参数。 +- `headers`:接口额外 Header。 +- `body`:请求体,内部通过 `AnyEncodable` 做类型擦除。 +- `Response`:接口成功后期望返回的数据类型。 + +示例: + +```swift +try await client.send( + APIRequest( + method: .post, + path: "/api/app/v9/login", + body: LoginRequest(username: username, password: password) + ) +) +``` + +业务 API 类应该负责创建 `APIRequest`,View 和 ViewModel 不应该直接拼 URL。 + +## URLRequest 构造规则 + +`APIClient.makeURLRequest` 会做这些事情: + +1. 如果 `path` 没有 `/` 前缀,会自动补上。 +2. 使用 `APIEnvironment.baseURL + path` 生成完整 URL。 +3. 如果 `queryItems` 非空,则写入 URL query。 +4. 设置 HTTP method。 +5. 设置公共 Header: + - `Content-Type: application/json` + - `Accept: application/json` + - `X-APP-VERSION` + - `X-OS-TYPE` +6. 合并业务 API 传入的额外 Header。 +7. 选择并注入 token。 +8. 如果有 body,则使用 `JSONEncoder` 编码为 JSON。 + +## token 注入规则 + +token 有两个来源: + +1. `tokenOverride` +2. `authTokenProvider` + +优先级是: + +```text +tokenOverride > authTokenProvider() +``` + +正常登录后的接口走 `authTokenProvider`。`RootView` 启动时会绑定: + +```swift +apiClient.bindAuthTokenProvider { appSession.token } +``` + +登录流程里的 `set-user` 比较特殊,它需要使用登录接口返回的临时 token,所以会传入 `tokenOverride`。 + +token 最终会写入请求 Header: + +```text +token: +``` + +空 token 不会写入 Header。 + +## 环境选择 + +`APIEnvironment.current` 根据编译环境选择接口地址: + +- Debug:`https://api-test.zhifly.cn` +- Release:`https://api.zhifly.cn` + +WebSocket 地址也在 `APIEnvironment` 中定义,当前网络客户端主要使用 HTTP baseURL。 + +## App 版本 Header + +`AppClientInfo.appVersion` 会从 `Info.plist` 读取: + +- `CFBundleShortVersionString` +- `CFBundleVersion` + +如果版本号已经有三段,则直接使用版本号。 +如果版本号只有两段,并且有 build 号,则拼成 `版本号.build`。 +如果读取失败,则兜底为 `1.0.0`。 + +这个值会作为 `X-APP-VERSION` Header 发送给后端。 + +系统类型固定为: + +```text +X-OS-TYPE: iOS +``` + +## 后端 Envelope 约定 + +后端统一响应结构由 `APIEnvelope` 表示: + +```swift +struct APIEnvelope: Decodable { + let data: T? + let code: Int + let msg: String? +} +``` + +当前成功业务码是: + +```text +100000 +``` + +只有 `code == 100000` 时才会继续解析 `data`。 + +如果接口成功但没有业务数据,使用 `EmptyPayload`: + +```swift +let _: EmptyPayload = try await client.send(...) +``` + +## 错误处理规则 + +网络错误分为几层: + +### 1. URL 构造错误 + +URL 拼接失败时抛出: + +```swift +APIError.invalidURL +``` + +### 2. URLSession 错误 + +`URLError` 会被转换成中文提示: + +- 超时:请求超时,请稍后重试 +- 无网络:网络不可用,请检查网络连接 +- 连接中断:网络连接中断,请重试 +- 无法连接服务器:无法连接服务器,请稍后重试 + +取消请求会继续抛出 `CancellationError`,不会包装成业务错误。 + +### 3. HTTP 状态码错误 + +非 2xx 状态码会抛出: + +```swift +APIError.httpStatus(statusCode, message) +``` + +错误文案优先从响应体里解析: + +1. `msg` +2. `message` +3. `error` +4. plain text 响应体 +5. 兜底文案“服务端返回错误” + +### 4. Envelope 解码错误 + +响应体无法按 `APIEnvelope` 解码时抛出: + +```swift +APIError.decodeFailed(message) +``` + +### 5. 后端业务码错误 + +HTTP 成功但 `code != 100000` 时抛出: + +```swift +APIError.serverCode(code, msg) +``` + +### 6. 空数据错误 + +接口声明需要返回 `Response`,但 Envelope 里没有 `data` 时抛出: + +```swift +APIError.emptyData +``` + +## 登录失效判断 + +`APIError.isAuthenticationExpired` 用于判断是否需要清空登录态。 + +会被视为登录失效的情况: +- HTTP 401 +- HTTP 403 +- 业务码 `200001` +- 错误文案包含: + - `token` + - `过期` + - `登录失效` + - `重新登录` + - `unauthorized` + - `验证失败` + - `驗證失敗` + +`SessionBootstrapper` 会用这个方法判断冷启动校验失败时是否要清空 token 和账号快照。 + +账号上下文相关接口集中在 `AccountContextAPI`: + +- `rolePermissions()` 读取角色权限。 +- `scenicListAll()` 读取景区列表。 +- `storeAll()` 读取门店列表。 +- `scenicSpotListAll(scenicId:)` 按景区读取景点/打卡点列表。 + +这些接口仍然只通过 `APIClient.send` 发起请求,token 由 `APIClient` 的 token provider 注入。 + +## Debug 日志 + +Debug 环境下,`APIClient` 会打印: + +- 请求方法和 URL +- 响应状态码 +- 格式化后的响应体 +- 被取消的请求信息 + +Release 环境不会打印这些日志。 + +## 新增接口的推荐写法 + +新增业务接口时,优先按这个结构写: + +```swift +@MainActor +@Observable +final class SomeFeatureAPI { + @ObservationIgnored private let client: APIClient + + init(client: APIClient) { + self.client = client + } + + func loadData() async throws -> SomeResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/example/path" + ) + ) + } +} +``` + +规则: +- API 类只负责接口封装,不处理 UI 状态。 +- ViewModel 调用 API 类,不直接调用 `APIClient`。 +- 请求体单独定义 `Encodable` Model。 +- 响应体单独定义 `Decodable` Model。 +- 接口成功无 data 时使用 `EmptyPayload`。 +- 需要临时 token 的接口使用 `tokenOverride`。 + +## 当前注意点 + +- `APIClient` 是 `@MainActor @Observable`,当前用于方便通过 Environment 注入和共享。网络发送本身是 async,不会阻塞主线程等待网络返回。 +- `URLSessionProtocol` 用于后续单元测试注入假 session。 +- `APIEnvelope.isSuccess` 当前只认 `100000`,如果后端未来新增成功码,需要集中改这里。 +- `APIEnvironment.current` 在 Debug 下默认测试环境,真机调试时需要注意接口环境。 diff --git a/suixinkan_ios/Core/Push/PushAPI.swift b/suixinkan_ios/Core/Push/PushAPI.swift new file mode 100644 index 0000000..54a1188 --- /dev/null +++ b/suixinkan_ios/Core/Push/PushAPI.swift @@ -0,0 +1,30 @@ +// +// PushAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import Foundation + +@MainActor +/// 推送 API,负责把 iOS APNs token 上报到当前后端兼容接口。 +final class PushAPI { + private let client: APIClient + + /// 初始化推送 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 上报 APNs device token。后端当前沿用 Android/JPush 字段名。 + func registerJPushId(_ registrationId: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/user/register-jpush-id", + queryItems: [URLQueryItem(name: "jpush_reg_id", value: registrationId)] + ) + ) + } +} diff --git a/suixinkan_ios/Core/Push/PushNotificationManager.swift b/suixinkan_ios/Core/Push/PushNotificationManager.swift new file mode 100644 index 0000000..480937b --- /dev/null +++ b/suixinkan_ios/Core/Push/PushNotificationManager.swift @@ -0,0 +1,151 @@ +// +// PushNotificationManager.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit +import UserNotifications + +@MainActor +/// 推送生命周期管理器,负责权限、APNs 注册、token 上报和通知点击路由。 +final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate { + static let shared = PushNotificationManager() + + private var api: PushAPI? + private weak var session: AppSession? + private weak var router: AppRouter? + private var latestDeviceToken: String? + private var latestAuthorizationStatus: UNAuthorizationStatus = .notDetermined + private let tokenDefaultsKey = "apns_device_token" + private let uploadedTokenDefaultsKey = "apns_uploaded_token" + + override init() { + super.init() + } + + /// 绑定推送所需的网络、登录和导航依赖。 + func configure(api: PushAPI, session: AppSession, router: AppRouter) { + self.api = api + self.session = session + self.router = router + UNUserNotificationCenter.current().delegate = self + latestDeviceToken = UserDefaults.standard.string(forKey: tokenDefaultsKey) + } + + /// 请求通知权限并注册 APNs;已授权时会直接注册并尝试补传缓存 token。 + func requestAuthorizationAndRegister() { + Task { @MainActor in + let settings = await UNUserNotificationCenter.current().notificationSettings() + latestAuthorizationStatus = settings.authorizationStatus + switch settings.authorizationStatus { + case .notDetermined: + let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false + guard granted else { return } + UIApplication.shared.registerForRemoteNotifications() + case .authorized, .provisional, .ephemeral: + UIApplication.shared.registerForRemoteNotifications() + uploadPendingTokenIfPossible() + case .denied: + break + @unknown default: + break + } + } + } + + /// 处理系统返回的 APNs token。 + func handleDeviceToken(_ deviceToken: Data) { + let token = APNsDeviceToken.hexString(from: deviceToken) + latestDeviceToken = token + UserDefaults.standard.set(token, forKey: tokenDefaultsKey) + uploadPendingTokenIfPossible() + } + + /// 处理 APNs 注册失败。失败不阻断登录和主流程。 + func handleRegistrationError(_ error: Error) { + #if DEBUG + print("APNs registration failed: \(error.localizedDescription)") + #endif + } + + /// 登录后补传尚未成功上报的 token。 + func uploadPendingTokenIfPossible() { + guard let api, session?.isLoggedIn == true else { return } + guard let token = latestDeviceToken, !token.isEmpty else { return } + let uploaded = UserDefaults.standard.string(forKey: uploadedTokenDefaultsKey) + guard uploaded != token else { return } + + Task { + do { + try await api.registerJPushId(token) + UserDefaults.standard.set(token, forKey: uploadedTokenDefaultsKey) + } catch { + #if DEBUG + print("Push token upload failed: \(error.localizedDescription)") + #endif + } + } + } + + /// 处理静默或远程通知 userInfo。 + func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) { + route(from: PushPayload(userInfo: userInfo)) + } + + /// 处理已解析的推送 payload。供系统回调和单测复用。 + func handleRemoteNotification(_ payload: PushPayload) { + route(from: payload) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + let payload = PushPayload(userInfo: notification.request.content.userInfo) + Task { @MainActor in + handleRemoteNotification(payload) + } + completionHandler([.banner, .list, .sound, .badge]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let payload = PushPayload(userInfo: response.notification.request.content.userInfo) + Task { @MainActor in + handleRemoteNotification(payload) + completionHandler() + } + } + + private func route(from payload: PushPayload) { + guard let router else { return } + + switch payload.route { + case .payment: + navigateHomeRoute(.paymentCollection) + case .order: + router.selectOrders(entry: .storeOrders) + case .verificationOrder: + router.selectOrders(entry: .verificationOrders) + case .task: + navigateHomeRoute(.taskManagement) + case .queue: + navigateHomeRoute(.queueManagement) + case .messageCenter: + navigateHomeRoute(.messageCenter) + } + } + + private func navigateHomeRoute(_ route: HomeRoute) { + guard let router else { return } + router.select(.home) + router.router(for: .home).navigate(to: .home(route)) + UIKitAppNavigation.pushHomeRoute(route, animated: true) + } +} diff --git a/suixinkan_ios/Core/Push/PushPayload.swift b/suixinkan_ios/Core/Push/PushPayload.swift new file mode 100644 index 0000000..64554e8 --- /dev/null +++ b/suixinkan_ios/Core/Push/PushPayload.swift @@ -0,0 +1,99 @@ +// +// PushPayload.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import Foundation + +/// APNs token 工具,把系统返回的 Data 转成后端可接收的小写十六进制字符串。 +enum APNsDeviceToken { + static func hexString(from data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } +} + +/// 推送 payload 解析器,兼容顶层字段和第三方通道常见 extras/data 嵌套结构。 +struct PushPayload: Sendable { + enum Route: Sendable, Equatable { + case payment + case order + case verificationOrder + case task + case queue + case messageCenter + } + + private let values: [String: String] + + nonisolated init(userInfo: [AnyHashable: Any]) { + var result: [String: String] = [:] + for (key, value) in userInfo { + guard let key = key as? String else { continue } + result[key] = Self.stringValue(value) + if key == "extras" || key == "extra" || key == "data" || key == "JMessageExtra" { + Self.mergeJSON(value, into: &result) + } + } + values = result + } + + nonisolated var route: Route { + let typeText = values["type"] ?? "" + let routeText = values["route"] ?? "" + let uriText = values["uri"] ?? "" + let actionText = values["action"] ?? "" + let merged = [typeText, routeText, uriText, actionText].joined(separator: " ").lowercased() + + if typeText == "1" || merged.contains("payment") || merged.contains("pay") || merged.contains("收款") { + return .payment + } + if merged.contains("queue") || merged.contains("排队") || merged.contains("叫号") || uriText == "/scenic-queue" { + return .queue + } + if merged.contains("verification") || merged.contains("writeoff") || merged.contains("核销") { + return .verificationOrder + } + if merged.contains("order") || merged.contains("订单") { + return .order + } + if merged.contains("task") || merged.contains("任务") { + return .task + } + return .messageCenter + } + + nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) { + if let dict = value as? [String: Any] { + for (key, value) in dict { + result[key] = stringValue(value) + mergeJSON(value, into: &result) + } + return + } + + guard let text = value as? String, + let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return } + + for (key, value) in object { + result[key] = stringValue(value) + mergeJSON(value, into: &result) + } + } + + nonisolated private static func stringValue(_ value: Any?) -> String { + switch value { + case let string as String: + return string + case let number as NSNumber: + return number.stringValue + case .some(let value): + return "\(value)" + case nil: + return "" + } + } +} diff --git a/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift b/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift new file mode 100644 index 0000000..769723a --- /dev/null +++ b/suixinkan_ios/Core/Queue/ScenicQueueAnnouncementState.swift @@ -0,0 +1,73 @@ +// +// ScenicQueueAnnouncementState.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队语音播报内容。 +struct ScenicQueueAnnouncement: Equatable { + enum Kind: Equatable { + case newTickets(count: Int) + case calledTicket(id: Int64) + } + + let kind: Kind + let text: String +} + +/// 排队播报去重状态,避免同一批队列反复播报。 +struct ScenicQueueAnnouncementState { + private var knownTicketIds = Set() + private var calledTicketIds = Set() + + /// 根据最新统计和列表计算下一条需要播报的内容。 + mutating func nextAnnouncement( + stats: ScenicQueueStatsData, + tickets: [ScenicQueueTicket], + customCallText: String? + ) -> ScenicQueueAnnouncement? { + let currentIds = Set(tickets.map(\.id)) + let newTickets = tickets.filter { !knownTicketIds.contains($0.id) } + knownTicketIds = currentIds + + if let ticket = tickets.first(where: { ($0.isCalled == 1 || $0.statusText.contains("已叫号")) && !calledTicketIds.contains($0.id) }) { + calledTicketIds.insert(ticket.id) + return ScenicQueueAnnouncement(kind: .calledTicket(id: ticket.id), text: Self.callText(for: ticket, customText: customCallText)) + } + + guard !newTickets.isEmpty else { return nil } + let text = "排队提醒,当前新增\(newTickets.count)位游客,在排人数\(stats.queueCount)人。" + return ScenicQueueAnnouncement(kind: .newTickets(count: newTickets.count), text: text) + } + + /// 重置播报去重状态。 + mutating func reset() { + knownTicketIds.removeAll() + calledTicketIds.removeAll() + } + + /// 生成叫号播报文案。 + static func callText(for ticket: ScenicQueueTicket, customText: String?) -> String { + callText(forQueueCode: ticket.queueCode, customText: customText) + } + + /// 生成叫号播报文案。 + static func callText(forQueueCode queueCode: String, customText: String?) -> String { + let custom = customText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !custom.isEmpty, custom != "A0001" { + return custom.replacingOccurrences(of: "{number}", with: queueCode) + } + let code = queueCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "当前游客" : ttsLabel(for: queueCode) + return "请\(code)到拍摄点拍摄。" + } + + /// 将排队号拆成更适合语音播报的文本。 + static func ttsLabel(for queueCode: String) -> String { + let trimmed = queueCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "当前游客" } + return trimmed.map(String.init).joined(separator: " ") + } +} diff --git a/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift b/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift new file mode 100644 index 0000000..fb9dd0a --- /dev/null +++ b/suixinkan_ios/Core/Queue/ScenicQueueRuntime.swift @@ -0,0 +1,295 @@ +// +// ScenicQueueRuntime.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import AVFoundation +import UIKit + +/// 应用前后台阶段,供排队运行时判断是否继续轮询。 +enum AppScenePhase { + case active + case inactive + case background +} + +@MainActor +/// 排队运行时,页面外按开关短轮询当前打卡点并语音播报队列变化。 +final class ScenicQueueRuntime { + + var onChange: (() -> Void)? + private(set) var isMonitoring = false { didSet { onChange?() } } + private(set) var lastPollText = "--" { didSet { onChange?() } } + private(set) var lastSpokenText = "" { didSet { onChange?() } } + private(set) var lastQueueCount = 0 { didSet { onChange?() } } + private(set) var lastError: String? { didSet { onChange?() } } + + private let speaker = ScenicQueueSpeechService() + private weak var api: (any ScenicQueueServing)? + private var userId: String? + private var scenicId: Int? + private var scenePhase: AppScenePhase = .active + private var pollTask: Task? + private var backgroundTaskId: UIBackgroundTaskIdentifier = .invalid + private var announcementState = ScenicQueueAnnouncementState() + private var lastScenicId: Int? + private var lastSpotId: Int? + private var suspendedByQueueScreen = false + + /// 语音播报是否开启。 + var voiceEnabled: Bool { + get { + if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.ttsEnabledKey) == nil { return true } + return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.ttsEnabledKey) + } + set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.ttsEnabledKey) } + } + + /// 后台短时轮询是否开启。 + var backgroundPollingEnabled: Bool { + get { + if UserDefaults.standard.object(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) == nil { return false } + return UserDefaults.standard.bool(forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) + } + set { UserDefaults.standard.set(newValue, forKey: ScenicQueueLocalSettings.backgroundPollEnabledKey) } + } + + /// 更新运行时上下文。 + func update( + api: any ScenicQueueServing, + userId: String?, + scenicId: Int?, + scenePhase: AppScenePhase + ) { + self.api = api + self.userId = userId + self.scenicId = scenicId + self.scenePhase = scenePhase + + guard !suspendedByQueueScreen else { + stop() + return + } + + guard let scenicId else { + stop() + resetSnapshot() + return + } + + let spotId = selectedSpotId() + guard spotId > 0 else { + stop() + lastError = "排队监听未启动:请先在排队设置里选择打卡点" + return + } + + if lastScenicId != scenicId || lastSpotId != spotId { + resetSnapshot() + lastScenicId = scenicId + lastSpotId = spotId + } + + if scenePhase == .background, !backgroundPollingEnabled { + stop() + return + } + + startIfNeeded() + } + + /// 页面自身接管实时监听时暂停运行时轮询。 + func setSuspendedByQueueScreen(_ suspended: Bool) { + guard suspendedByQueueScreen != suspended else { return } + suspendedByQueueScreen = suspended + if suspended { + stop() + } else if let api { + update(api: api, userId: userId, scenicId: scenicId, scenePhase: scenePhase) + } + } + + /// 停止运行时轮询和播报。 + func stop() { + pollTask?.cancel() + pollTask = nil + isMonitoring = false + speaker.stop() + endBackgroundTask() + } + + /// 立即轮询一次。 + func pollNow() { + guard pollTask != nil else { return } + Task { await pollOnce() } + } + + private func startIfNeeded() { + guard pollTask == nil else { return } + isMonitoring = true + if scenePhase == .background { + beginBackgroundTask() + } + pollTask = Task { [weak self] in + await self?.runLoop() + } + } + + private func runLoop() async { + while !Task.isCancelled { + await pollOnce() + let seconds = pollIntervalSeconds() + try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000) + } + } + + private func pollOnce() async { + guard let api, let scenicId else { return } + let spotId = selectedSpotId() + guard spotId > 0 else { return } + + if scenePhase == .background { + beginBackgroundTask() + } + + do { + if lastScenicId != scenicId || lastSpotId != spotId { + resetSnapshot() + lastScenicId = scenicId + lastSpotId = spotId + } + async let statsData = api.scenicQueueStats(scenicId: scenicId, scenicSpotId: spotId) + async let homeData = api.scenicQueueHome( + scenicId: scenicId, + scenicSpotId: spotId, + type: QueueListType.queueing.rawValue, + page: 1, + pageSize: 20 + ) + let (stats, home) = try await (statsData, homeData) + handle(stats: stats, tickets: home.list?.list ?? []) + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func handle(stats: ScenicQueueStatsData, tickets: [ScenicQueueTicket]) { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + lastPollText = formatter.string(from: Date()) + lastQueueCount = stats.queueCount + + guard voiceEnabled else { return } + let customText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: lastSpotId) + if let announcement = announcementState.nextAnnouncement(stats: stats, tickets: tickets, customCallText: customText) { + lastSpokenText = announcement.text + speaker.speak(announcement.text) + } + } + + private func pollIntervalSeconds() -> UInt64 { + switch scenePhase { + case .active: + return 12 + case .inactive: + return 20 + case .background: + return 30 + @unknown default: + return 20 + } + } + + private func selectedSpotId() -> Int { + ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) + ?? UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) + } + + private func resetSnapshot() { + announcementState.reset() + lastQueueCount = 0 + lastPollText = "--" + lastSpokenText = "" + } + + private func beginBackgroundTask() { + guard backgroundTaskId == .invalid else { return } + backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "scenic.queue.poll") { [weak self] in + Task { @MainActor in + self?.endBackgroundTask() + self?.stop() + } + } + } + + private func endBackgroundTask() { + guard backgroundTaskId != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTaskId) + backgroundTaskId = .invalid + } +} + +@MainActor +/// 排队语音播报服务。 +final class ScenicQueueSpeechService: NSObject, AVSpeechSynthesizerDelegate { + private let synthesizer = AVSpeechSynthesizer() + private var pendingContinuations: [CheckedContinuation] = [] + + override init() { + super.init() + synthesizer.delegate = self + } + + /// 播放语音文本。 + func speak(_ text: String) { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + enqueue(normalized, continuation: nil, replacePending: true) + } + + /// 播放语音并等待结束,供测试和设置页试听使用。 + func speakAndWait(_ text: String) async { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + await withCheckedContinuation { continuation in + enqueue(normalized, continuation: continuation, replacePending: false) + } + } + + /// 停止语音播报。 + func stop() { + synthesizer.stopSpeaking(at: .immediate) + resumePending() + } + + private func enqueue(_ normalized: String, continuation: CheckedContinuation?, replacePending: Bool) { + if replacePending { + synthesizer.stopSpeaking(at: .immediate) + resumePending() + } + if let continuation { + pendingContinuations.append(continuation) + } + let utterance = AVSpeechUtterance(string: normalized) + utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN") + utterance.rate = 0.48 + synthesizer.speak(utterance) + } + + nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + Task { @MainActor in self.resumePending() } + } + + nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + Task { @MainActor in self.resumePending() } + } + + private func resumePending() { + let continuations = pendingContinuations + pendingContinuations.removeAll() + continuations.forEach { $0.resume() } + } +} diff --git a/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift b/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift new file mode 100644 index 0000000..7d0d814 --- /dev/null +++ b/suixinkan_ios/Core/Queue/ScenicQueueSocketClient.swift @@ -0,0 +1,166 @@ +// +// ScenicQueueSocketClient.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队 WebSocket 消息。 +struct ScenicQueueSocketMessage: Equatable { + static let queueUpdatedAction = "scenic_spot_queue_updated" + static let ticketCalledAction = "scenic_queue_ticket_called" + + let code: Int + let data: ScenicQueueSocketData? + + var isScenicQueueEvent: Bool { + let action = data?.action ?? "" + return action == Self.queueUpdatedAction || action == Self.ticketCalledAction + } +} + +/// 排队 WebSocket data 块。 +struct ScenicQueueSocketData: Equatable { + let action: String + let params: ScenicQueueSocketParams? +} + +/// 排队 WebSocket 参数。 +struct ScenicQueueSocketParams: Equatable { + let scenicSpotId: Int64? + let recordId: Int64? + let operatorUid: Int64? + let eventId: String? +} + +@MainActor +/// 排队 WebSocket 客户端,负责订阅排队更新和叫号事件。 +final class ScenicQueueSocketClient { + private var webSocketTask: URLSessionWebSocketTask? + private var receiveTask: Task? + + /// 连接 WebSocket 并订阅指定打卡点的排队事件。 + func connect( + socketToken: String, + scenicSpotId: Int, + onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void + ) { + let token = socketToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { return } + disconnect(reason: "Reconnect before scenic queue socket connect") + + let task = URLSession.shared.webSocketTask(with: APIEnvironment.current.webSocketURL) + webSocketTask = task + task.resume() + + receiveTask = Task { [weak self] in + await self?.receiveLoop(task: task, onMessage: onMessage) + } + + Task { + for payload in Self.subscriptionPayloads(scenicSpotId: scenicSpotId) { + try? await task.send(.string(payload)) + } + } + } + + /// 断开 WebSocket。 + func disconnect(reason: String = "Scenic queue page stopped") { + receiveTask?.cancel() + receiveTask = nil + webSocketTask?.cancel(with: .normalClosure, reason: reason.data(using: .utf8)) + webSocketTask = nil + } + + private func receiveLoop( + task: URLSessionWebSocketTask, + onMessage: @escaping @MainActor (ScenicQueueSocketMessage) -> Void + ) async { + while !Task.isCancelled { + do { + let message = try await task.receive() + let text: String? + switch message { + case .string(let value): + text = value + case .data(let data): + text = String(data: data, encoding: .utf8) + @unknown default: + text = nil + } + guard let text, let parsed = Self.parseMessage(text), parsed.isScenicQueueEvent else { continue } + onMessage(parsed) + } catch { + return + } + } + } + + /// 构造订阅 payload,type 与 Android 保持一致。 + static func subscriptionPayloads(scenicSpotId: Int) -> [String] { + [ + #"{"type":306,"params":{"scenic_spot_id":\#(scenicSpotId)}}"#, + #"{"type":307,"params":{"scenic_spot_id":\#(scenicSpotId)}}"# + ] + } + + /// 解析排队 WebSocket 原始消息。 + static func parseMessage(_ raw: String) -> ScenicQueueSocketMessage? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("{"), let data = trimmed.data(using: .utf8) else { return nil } + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + + let code = intValue(root["code"]) ?? 0 + var socketData: ScenicQueueSocketData? + if let dataObject = root["data"] as? [String: Any] { + let action = stringValue(dataObject["action"]) + var params: ScenicQueueSocketParams? + if let paramsObject = dataObject["params"] as? [String: Any] { + params = ScenicQueueSocketParams( + scenicSpotId: int64Value(paramsObject["scenic_spot_id"]), + recordId: int64Value(paramsObject["record_id"]), + operatorUid: int64Value(paramsObject["operator_uid"]), + eventId: stringValue(paramsObject["event_id"]).trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + ) + } + socketData = ScenicQueueSocketData(action: action, params: params) + } + return ScenicQueueSocketMessage(code: code, data: socketData) + } + + private static func stringValue(_ value: Any?) -> String { + switch value { + case let value as String: + return value + case let value as NSNumber: + return value.stringValue + case let value?: + return "\(value)" + case nil: + return "" + } + } + + private static func intValue(_ value: Any?) -> Int? { + if let value = value as? Int { return value } + if let value = value as? NSNumber { return value.intValue } + if let value = value as? String { return Int(value) } + return nil + } + + private static func int64Value(_ value: Any?) -> Int64? { + if let value = value as? Int64 { return value } + if let value = value as? Int { return Int64(value) } + if let value = value as? NSNumber { return value.int64Value } + if let value = value as? String { return Int64(value) } + return nil + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan_ios/Core/Storage/AccountSnapshotStore.swift b/suixinkan_ios/Core/Storage/AccountSnapshotStore.swift new file mode 100644 index 0000000..3da04fd --- /dev/null +++ b/suixinkan_ios/Core/Storage/AccountSnapshotStore.swift @@ -0,0 +1,105 @@ +// +// AccountSnapshotStore.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation + +/// 账号缓存快照实体,保存可重建、非敏感的账号展示和业务上下文。 +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 + } +} + +/// 账号快照存储服务,使用 UserDefaults 保存非敏感登录上下文。 +final class AccountSnapshotStore { + private let defaults: UserDefaults + private let key: String + private let encoder: JSONEncoder + 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) + } + + /// 仅更新当前业务选择,保留账号资料、账号类型和可选作用域等其他快照内容。 + @MainActor + func saveCurrentSelection( + accountContext: AccountContext, + currentRoleId: Int? + ) { + let existing = load() + save( + AccountSnapshot( + profile: accountContext.profile ?? existing?.profile, + accountType: existing?.accountType, + businessUserId: existing?.businessUserId, + currentRoleId: currentRoleId ?? existing?.currentRoleId, + scenicScopes: accountContext.scenicScopes, + storeScopes: accountContext.storeScopes, + currentScenicId: accountContext.currentScenic?.id, + currentStoreId: accountContext.currentStore?.id + ) + ) + } +} diff --git a/suixinkan_ios/Core/Storage/AppPreferencesStore.swift b/suixinkan_ios/Core/Storage/AppPreferencesStore.swift new file mode 100644 index 0000000..185c144 --- /dev/null +++ b/suixinkan_ios/Core/Storage/AppPreferencesStore.swift @@ -0,0 +1,50 @@ +// +// AppPreferencesStore.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation + +/// App 偏好存储服务,保存上次手机号和协议状态等非敏感设置。 +final class AppPreferencesStore { + private let defaults: UserDefaults + private let lastLoginUsernameKey = "suixinkan.preferences.last_login_username" + private let privacyAgreementAcceptedKey = "suixinkan.preferences.privacy_agreement_accepted" + + /// 初始化偏好存储服务,并允许测试注入独立的 UserDefaults。 + init(defaults: UserDefaults = .standard) { + 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) + } + + /// 清空登录页偏好。 + func clear() { + defaults.removeObject(forKey: lastLoginUsernameKey) + defaults.removeObject(forKey: privacyAgreementAcceptedKey) + } +} diff --git a/suixinkan_ios/Core/Storage/SessionTokenStore.swift b/suixinkan_ios/Core/Storage/SessionTokenStore.swift new file mode 100644 index 0000000..e828541 --- /dev/null +++ b/suixinkan_ios/Core/Storage/SessionTokenStore.swift @@ -0,0 +1,100 @@ +// +// SessionTokenStore.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation +import Security + +/// 登录 token 存储错误实体,表示 Keychain 读写失败的具体状态。 +enum SessionTokenStoreError: LocalizedError { + case unexpectedStatus(OSStatus) + + var errorDescription: String? { + switch self { + case let .unexpectedStatus(status): + "登录凭证存储失败(\(status))" + } + } +} + +/// 正式登录 token 存储服务,封装 Keychain 读写并避免业务层接触安全 API。 +final class SessionTokenStore { + private let service: String + private let account: String + + /// 初始化 token 存储服务,默认按 App Bundle 隔离 Keychain 项。 + init( + service: String = Bundle.main.bundleIdentifier ?? "com.yuanzhixiang.suixinkan", + account: String = "session.token" + ) { + self.service = service + self.account = account + } + + /// 保存正式 token,空 token 会被视为清空凭证。 + func save(_ token: String) throws { + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedToken.isEmpty else { + try clear() + return + } + + let data = Data(trimmedToken.utf8) + let query = baseQuery() + let updateAttributes: [String: Any] = [kSecValueData as String: data] + + let updateStatus = SecItemUpdate(query as CFDictionary, updateAttributes as CFDictionary) + if updateStatus == errSecSuccess { + return + } + guard updateStatus == errSecItemNotFound else { + throw SessionTokenStoreError.unexpectedStatus(updateStatus) + } + + var addQuery = query + updateAttributes.forEach { addQuery[$0.key] = $0.value } + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw SessionTokenStoreError.unexpectedStatus(addStatus) + } + } + + /// 读取本地正式 token,读取失败或无值时返回 nil。 + func load() -> String? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let token = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty else { + return nil + } + return token + } + + /// 清空本地正式 token。 + func clear() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw SessionTokenStoreError.unexpectedStatus(status) + } + } + + /// 构造当前 App 使用的 Keychain 查询条件。 + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } +} diff --git a/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift b/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift new file mode 100644 index 0000000..f1d3e54 --- /dev/null +++ b/suixinkan_ios/Core/UI/FeaturePlaceholderViewController.swift @@ -0,0 +1,62 @@ +// +// FeaturePlaceholderViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 功能迁移占位页,承接尚未 UIKit 化的首页菜单入口。 +final class FeaturePlaceholderViewController: UIViewController { + + private let pageTitle: String + private let uri: String + + init(title: String, uri: String) { + self.pageTitle = title + self.uri = uri + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(hex: 0xF5F7FA) + title = pageTitle + + let icon = UIImageView(image: UIImage(systemName: "square.grid.2x2")) + icon.tintColor = AppDesignUIKit.primary + icon.contentMode = .scaleAspectFit + + let titleLabel = UILabel() + titleLabel.text = pageTitle + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .semibold) + titleLabel.textColor = AppDesignUIKit.textPrimary + titleLabel.textAlignment = .center + + let uriLabel = UILabel() + uriLabel.text = uri + uriLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + uriLabel.textColor = AppDesignUIKit.textSecondary + uriLabel.textAlignment = .center + uriLabel.numberOfLines = 0 + + let stack = UIStackView(arrangedSubviews: [icon, titleLabel, uriLabel]) + stack.axis = .vertical + stack.spacing = AppMetrics.Spacing.medium + stack.alignment = .center + view.addSubview(stack) + + icon.snp.makeConstraints { make in + make.width.height.equalTo(44) + } + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large) + } + } +} diff --git a/suixinkan_ios/Core/UI/RemoteImage.swift b/suixinkan_ios/Core/UI/RemoteImage.swift new file mode 100644 index 0000000..5bcb70e --- /dev/null +++ b/suixinkan_ios/Core/UI/RemoteImage.swift @@ -0,0 +1,107 @@ +// +// RemoteImage.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Kingfisher +import UIKit + +extension UIImageView { + /// 使用 Kingfisher 加载远程图片,并提供占位图与失败回退。 + func loadRemoteImage( + urlString: String?, + contentMode: UIView.ContentMode = .scaleAspectFill, + placeholder: UIImage? = nil, + failureImage: UIImage? = nil + ) { + let normalized = normalizedURLString(from: urlString) + self.contentMode = contentMode + + guard let normalized, let url = URL(string: normalized) else { + kf.cancelDownloadTask() + image = failureImage ?? placeholder + return + } + + kf.setImage( + with: url, + placeholder: placeholder, + options: [ + .cacheOriginalImage, + .transition(.fade(0.2)) + ] + ) { [weak self] result in + guard let self else { return } + if case .failure = result { + self.image = failureImage ?? placeholder + } + } + } + + /// 加载远程圆形头像,失败时展示系统图标占位。 + func loadRemoteAvatar( + urlString: String, + systemImageName: String = "person.fill", + iconSize: CGFloat = 44 + ) { + clipsToBounds = true + layer.cornerRadius = min(bounds.width, bounds.height) / 2 + + let placeholder = Self.avatarPlaceholder( + systemImageName: systemImageName, + iconSize: iconSize, + size: bounds.size == .zero ? CGSize(width: iconSize, height: iconSize) : bounds.size + ) + + loadRemoteImage( + urlString: urlString, + contentMode: .scaleAspectFill, + placeholder: placeholder, + failureImage: placeholder + ) + } + + /// 取消当前远程图片加载任务。 + func cancelRemoteImageLoad() { + kf.cancelDownloadTask() + } + + private func normalizedURLString(from urlString: String?) -> String? { + let text = urlString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } + + private static func avatarPlaceholder( + systemImageName: String, + iconSize: CGFloat, + size: CGSize + ) -> UIImage? { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { context in + AppDesign.primarySoft.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + let configuration = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .semibold) + guard let symbol = UIImage(systemName: systemImageName, withConfiguration: configuration)? + .withTintColor(AppDesign.primary, renderingMode: .alwaysOriginal) else { + return + } + + let origin = CGPoint( + x: (size.width - symbol.size.width) / 2, + y: (size.height - symbol.size.height) / 2 + ) + symbol.draw(at: origin) + } + } +} + +extension UIImageView { + /// 在布局变化后刷新圆形头像圆角。 + func refreshRemoteAvatarCornerRadius() { + guard bounds.width > 0, bounds.height > 0 else { return } + layer.cornerRadius = min(bounds.width, bounds.height) / 2 + } +} diff --git a/suixinkan_ios/Core/UI/UIColor+AppDesign.swift b/suixinkan_ios/Core/UI/UIColor+AppDesign.swift new file mode 100644 index 0000000..83a092c --- /dev/null +++ b/suixinkan_ios/Core/UI/UIColor+AppDesign.swift @@ -0,0 +1,31 @@ +// +// UIColor+AppDesign.swift +// suixinkan +// + +import UIKit + +extension UIColor { + /// 通过 0xRRGGBB 和透明度创建 UIColor。 + convenience init(hex: UInt, alpha: CGFloat = 1.0) { + self.init( + red: CGFloat((hex >> 16) & 0xff) / 255.0, + green: CGFloat((hex >> 8) & 0xff) / 255.0, + blue: CGFloat(hex & 0xff) / 255.0, + alpha: alpha + ) + } +} + +/// UIKit 设计色值,与 SwiftUI `AppDesign` 保持一致。 +enum AppDesignUIKit { + static let primary = UIColor(hex: 0x0073FF) + static let primarySoft = UIColor(hex: 0xEFF6FF) + static let textPrimary = UIColor(hex: 0x1F2937) + static let textSecondary = UIColor(hex: 0x6B7280) + static let placeholder = UIColor(hex: 0xA8B2C1) + static let success = UIColor(hex: 0x14964A) + static let warning = UIColor(hex: 0xFF7B00) + static let pageBackground = UIColor(hex: 0xF5F5F5) + static let cardBackground = UIColor.white +} diff --git a/suixinkan_ios/Core/UI/ViewControllerHelpers.swift b/suixinkan_ios/Core/UI/ViewControllerHelpers.swift new file mode 100644 index 0000000..46011d5 --- /dev/null +++ b/suixinkan_ios/Core/UI/ViewControllerHelpers.swift @@ -0,0 +1,111 @@ +// +// ViewControllerHelpers.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// ViewController 通用工具,封装 Toast、Loading 和 ViewModel 绑定。 +@MainActor +enum ViewControllerHelpers { + static var services: AppServices { AppServices.shared } + + /// 绑定 ViewModel 的 onChange 回调并在 deinit 时自动清理。 + static func bind(onChange: (() -> Void)?, owner: AnyObject, handler: @escaping () -> Void) { + onChange?() + _ = ViewModelBindingToken(owner: owner, handler: handler) + } +} + +/// 持有 ViewModel onChange 闭包的弱引用令牌。 +private final class ViewModelBindingToken { + private weak var owner: AnyObject? + private let handler: () -> Void + + init(owner: AnyObject, handler: @escaping () -> Void) { + self.owner = owner + self.handler = handler + } + + deinit { + handler() + } +} + +extension UIViewController { + var appServices: AppServices { AppServices.shared } + + func showToast(_ message: String) { + appServices.toastCenter.show(message) + } + + func showGlobalLoading(_ message: String = "") { + appServices.globalLoading.show(message: message) + } + + func hideGlobalLoading() { + appServices.globalLoading.hide() + } + + /// 创建带圆角白底的卡片容器。 + func makeCardView(cornerRadius: CGFloat = 8) -> UIView { + let view = UIView() + view.backgroundColor = AppDesignUIKit.cardBackground + view.layer.cornerRadius = cornerRadius + view.clipsToBounds = true + return view + } + + /// 创建空状态占位视图。 + func makeEmptyStateView(title: String, message: String, systemImage: String) -> UIView { + let container = UIView() + + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .center + stack.spacing = AppMetrics.Spacing.small + + let imageView = UIImageView(image: UIImage(systemName: systemImage)) + imageView.tintColor = AppDesignUIKit.textSecondary + imageView.contentMode = .scaleAspectFit + imageView.snp.makeConstraints { make in + make.width.height.equalTo(48) + } + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold) + titleLabel.textColor = AppDesignUIKit.textPrimary + titleLabel.textAlignment = .center + + let messageLabel = UILabel() + messageLabel.text = message + messageLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + messageLabel.textColor = AppDesignUIKit.textSecondary + messageLabel.textAlignment = .center + messageLabel.numberOfLines = 0 + + stack.addArrangedSubview(imageView) + stack.addArrangedSubview(titleLabel) + stack.addArrangedSubview(messageLabel) + container.addSubview(stack) + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large) + } + return container + } + + /// 创建主色填充按钮。 + func makePrimaryButton(title: String) -> UIButton { + var config = UIButton.Configuration.filled() + config.title = title + config.baseBackgroundColor = AppDesignUIKit.primary + config.baseForegroundColor = .white + config.cornerStyle = .medium + config.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16) + let button = UIButton(configuration: config) + return button + } +} diff --git a/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift b/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift new file mode 100644 index 0000000..a75f0a1 --- /dev/null +++ b/suixinkan_ios/Core/UIKit/ModuleViewControllerSupport.swift @@ -0,0 +1,156 @@ +// +// ModuleViewControllerSupport.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import SnapKit +import UIKit + +/// 通用双行列表 Cell,用于模块列表页展示标题、副标题和详情。 +final class TitleSubtitleTableViewCell: UITableViewCell { + static let reuseIdentifier = "TitleSubtitleTableViewCell" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) + selectionStyle = .default + textLabel?.font = .systemFont(ofSize: 16, weight: .medium) + textLabel?.textColor = AppDesign.textPrimary + textLabel?.numberOfLines = 2 + detailTextLabel?.font = .systemFont(ofSize: 13) + detailTextLabel?.textColor = AppDesign.textSecondary + detailTextLabel?.numberOfLines = 3 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(title: String, subtitle: String? = nil, detail: String? = nil) { + textLabel?.text = title + if let subtitle, !subtitle.isEmpty { + detailTextLabel?.text = subtitle + } else { + detailTextLabel?.text = detail + } + } +} + +/// 模块列表页基类,封装 UITableView、下拉刷新和 ViewModel onChange 绑定。 +@MainActor +class ModuleTableViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { + let services = AppServices.shared + let tableView = UITableView(frame: .zero, style: .insetGrouped) + private let refreshControl = UIRefreshControl() + private let activityIndicator = UIActivityIndicatorView(style: .medium) + private var viewModelReloadHandler: (() -> Void)? + + var isLoading = false { + didSet { + if isLoading, tableView.numberOfSections > 0, tableView.numberOfRows(inSection: 0) == 0 { + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(hex: 0xF5F7FA) + navigationItem.largeTitleDisplayMode = .never + + tableView.dataSource = self + tableView.delegate = self + tableView.backgroundColor = .clear + tableView.register( + TitleSubtitleTableViewCell.self, + forCellReuseIdentifier: TitleSubtitleTableViewCell.reuseIdentifier + ) + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(handleRefresh), for: .valueChanged) + + activityIndicator.hidesWhenStopped = true + view.addSubview(tableView) + view.addSubview(activityIndicator) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + activityIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + Task { await reloadContent() } + } + + func bindViewModel(onChange: (() -> Void)?) { + viewModelReloadHandler = onChange + } + + func reloadTable() { + tableView.reloadData() + } + + func tableRowCount() -> Int { 0 } + + func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {} + + func didSelectTableRow(at indexPath: IndexPath) {} + + func reloadContent() async {} + + @objc private func handleRefresh() { + Task { + await reloadContent() + refreshControl.endRefreshing() + } + } + + func numberOfSections(in tableView: UITableView) -> Int { 1 } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + tableRowCount() + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, + for: indexPath + ) as? TitleSubtitleTableViewCell else { + return UITableViewCell() + } + configureCell(cell, at: indexPath) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + didSelectTableRow(at: indexPath) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + willDisplayTableRow(at: indexPath) + } + + func willDisplayTableRow(at indexPath: IndexPath) {} +} + +extension ModuleTableViewController { + func wireViewModel(_ viewModel: AnyObject, reload: @escaping () -> Void) { + if let bindable = viewModel as? ViewModelBindable { + bindable.onChange = { [weak self] in + reload() + self?.reloadTable() + } + } + reload() + } +} + +/// ViewModel 通用绑定协议。 +@MainActor +protocol ViewModelBindable: AnyObject { + var onChange: (() -> Void)? { get set } +} diff --git a/suixinkan_ios/Core/Upload/OSSUploadService.swift b/suixinkan_ios/Core/Upload/OSSUploadService.swift new file mode 100644 index 0000000..85f18cd --- /dev/null +++ b/suixinkan_ios/Core/Upload/OSSUploadService.swift @@ -0,0 +1,283 @@ +// +// OSSUploadService.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import UniformTypeIdentifiers + +#if canImport(AlibabaCloudOSS) +import AlibabaCloudOSS +#endif + +/// OSS 上传服务协议,定义业务模块可复用的上传入口。 +@MainActor +protocol OSSUploadServing { + /// 上传用户头像,并返回可访问的 OSS 文件 URL。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传实名认证证件图片,并返回可访问的 OSS 文件 URL。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传云盘文件,并返回可访问的 OSS 文件 URL。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传相册文件,并返回可访问的 OSS 文件 URL。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传直播相册文件,并返回可访问的 OSS 文件 URL。 + func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传任务附件,并返回可访问的 OSS 文件 URL。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传项目图片,并返回可访问的 OSS 文件 URL。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传打卡点图片,并返回可访问的 OSS 文件 URL。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传景区申请图片,并返回可访问的 OSS 文件 URL。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传银行卡照片,并返回可访问的 OSS 文件 URL。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String + + /// 上传飞手认证证件图片,并返回可访问的 OSS 文件 URL。 + func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String +} + +@MainActor +/// OSS 上传服务,负责获取 STS 配置、校验文件、调用阿里云 SDK 并返回最终 URL。 +final class OSSUploadService { + private let configService: any OSSConfigServing + + /// 初始化 OSS 上传服务,并注入 STS 配置服务。 + init(configService: any OSSConfigServing) { + self.configService = configService + } + + /// 上传用户头像,并返回可访问的 OSS 文件 URL。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "user_avatar", onProgress: onProgress) + } + + /// 上传实名认证证件图片,并返回可访问的 OSS 文件 URL。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "real_name", onProgress: onProgress) + } + + /// 上传云盘文件,并返回可访问的 OSS 文件 URL。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "cloud_driver", onProgress: onProgress) + } + + /// 上传相册文件,并返回可访问的 OSS 文件 URL。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "album_upload", onProgress: onProgress) + } + + /// 上传直播相册文件,并返回可访问的 OSS 文件 URL。 + func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "alive_album", onProgress: onProgress) + } + + /// 上传任务附件,并返回可访问的 OSS 文件 URL。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId, moduleType: "task_upload", onProgress: onProgress) + } + + /// 上传项目图片,并返回可访问的 OSS 文件 URL。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "project", onProgress: onProgress) + } + + /// 上传打卡点图片,并返回可访问的 OSS 文件 URL。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "punch_point", onProgress: onProgress) + } + + /// 上传景区申请图片,并返回可访问的 OSS 文件 URL。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "scenic_apply", onProgress: onProgress) + } + + /// 上传银行卡照片,并返回可访问的 OSS 文件 URL。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress) + } + + /// 上传飞手认证证件图片,并返回可访问的 OSS 文件 URL。 + func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "pilot_cert", onProgress: onProgress) + } + + /// 执行通用 OSS 上传流程。 + private func uploadFile( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + moduleType: String, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + onProgress(1) + try OSSUploadPolicy.validate(dataSize: data.count, fileName: fileName) + + let config = try await configService.aliyunOSSBucket(bucket: "vipsky") + let objectKey = OSSUploadPolicy.objectKey(fileName: fileName, scenicId: scenicId, moduleType: moduleType) + onProgress(12) + + #if canImport(AlibabaCloudOSS) + let credentialsProvider = StaticCredentialsProvider( + accessKeyId: config.credentials.accessKeyId, + accessKeySecret: config.credentials.accessKeySecret, + securityToken: config.credentials.securityToken + ) + let clientConfig = Configuration.default() + .withRegion(config.region) + .withCredentialsProvider(credentialsProvider) + if !config.endpoint.isEmpty { + clientConfig.withEndpoint(config.endpoint) + } + onProgress(25) + let client = Client(clientConfig) + _ = try await client.putObject( + PutObjectRequest( + bucket: config.bucket, + key: objectKey, + contentType: OSSUploadPolicy.contentType(for: fileName, fileType: fileType), + body: .data(data), + progress: ProgressClosure { _, transferred, expected in + guard expected > 0 else { return } + let uploadProgress = Double(transferred) / Double(expected) + let progress = max(26, min(99, 25 + Int(uploadProgress * 74))) + onProgress(progress) + } + ) + ) + onProgress(100) + return OSSUploadPolicy.joinURL(baseURL: config.baseUrl, objectKey: objectKey) + #else + throw OSSUploadError.sdkUnavailable + #endif + } +} + +extension OSSUploadService: OSSUploadServing {} + +/// OSS 上传策略实体,集中管理文件限制、路径生成、MIME 类型和 URL 拼接规则。 +enum OSSUploadPolicy { + static let maxFileSize = 2_048 * 1_024 * 1_024 + private static let allowedExtensions: Set = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"] + + /// 校验待上传文件大小和扩展名。 + static func validate(dataSize: Int, fileName: String) throws { + guard dataSize > 0 else { + throw OSSUploadError.emptyFile + } + guard dataSize <= maxFileSize else { + throw OSSUploadError.fileTooLarge + } + let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() + guard allowedExtensions.contains(ext) else { + throw OSSUploadError.unsupportedFileType + } + } + + /// 根据业务模块、景区和文件名生成 OSS objectKey。 + static func objectKey( + fileName: String, + scenicId: Int, + moduleType: String, + date: Date = Date(), + uuid: UUID = UUID(), + timeZone: TimeZone = .current + ) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = timeZone + formatter.dateFormat = "yyyyMMdd" + let currentDate = formatter.string(from: date) + let uploadId = uuid.uuidString.replacingOccurrences(of: "-", with: "") + let safeName = sanitizedFileName(fileName) + switch moduleType { + case "task_upload": + return "task_upload/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "cloud_driver": + return "cloud_driver/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "album_upload": + return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "alive_album": + return "live_albums/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "project": + return "project/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "punch_point": + return "punch_point/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "scenic_apply": + return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "user_avatar": + return "avatar/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "real_name": + return "real_name/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "bank_card": + return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "pilot_cert": + return "pilot_cert/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + default: + return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + } + } + + /// 根据文件名扩展名和业务文件类型推断 MIME 类型。 + static func contentType(for fileName: String, fileType: Int) -> String { + let ext = URL(fileURLWithPath: fileName).pathExtension + if let type = UTType(filenameExtension: ext)?.preferredMIMEType { + return type + } + return fileType == 1 ? "video/mp4" : "image/jpeg" + } + + /// 拼接 OSS 访问域名和 objectKey。 + static func joinURL(baseURL: String, objectKey: String) -> String { + if baseURL.hasSuffix("/") { + return baseURL + objectKey + } + return baseURL + "/" + objectKey + } + + /// 清理文件名中的危险字符,避免生成非法 objectKey。 + private static func sanitizedFileName(_ fileName: String) -> String { + let trimmedName = fileName.trimmingCharacters(in: .whitespacesAndNewlines) + var unsafeCharacters = CharacterSet.controlCharacters + unsafeCharacters.formUnion(CharacterSet(charactersIn: "/\\")) + + let safeName = trimmedName.unicodeScalars.reduce(into: "") { result, scalar in + result += unsafeCharacters.contains(scalar) ? "_" : String(scalar) + } + return safeName.isEmpty ? "upload" : safeName + } +} + +/// OSS 上传错误实体,表示本地校验或 SDK 链接状态异常。 +enum OSSUploadError: LocalizedError, Equatable { + case sdkUnavailable + case emptyFile + case fileTooLarge + case unsupportedFileType + + var errorDescription: String? { + switch self { + case .sdkUnavailable: + "阿里云 OSS Swift SDK 尚未链接到当前 iOS target" + case .emptyFile: + "文件内容不能为空" + case .fileTooLarge: + "文件大小不能超过2048MB" + case .unsupportedFileType: + "仅支持.mp4,.mov,.m4v,.avi,.png,.jpg,.jpeg,.heic,.heif格式" + } + } +} diff --git a/suixinkan_ios/Core/Upload/Upload.md b/suixinkan_ios/Core/Upload/Upload.md new file mode 100644 index 0000000..6f91c45 --- /dev/null +++ b/suixinkan_ios/Core/Upload/Upload.md @@ -0,0 +1,52 @@ +# Upload 模块业务逻辑 + +## 模块职责 + +Upload 模块负责 App 内通用文件上传能力,当前主要服务个人头像和实名认证证件图片,后续云盘、相册、任务、打卡点等模块迁移时复用同一套 OSS 上传入口。 + +该模块不负责业务表单提交,只负责: +- 获取阿里云 OSS STS 临时配置。 +- 校验待上传文件大小和扩展名。 +- 生成按业务模块隔离的 OSS objectKey。 +- 调用阿里云 OSS Swift SDK 上传文件。 +- 返回最终可访问的文件 URL。 + +## 核心对象 + +- `UploadAPI`:封装 `/api/app/config/get-sts-token`,只负责获取 STS 临时上传配置。 +- `OSSUploadService`:统一上传服务,封装 SDK 调用和进度回调。 +- `OSSUploadPolicy`:上传策略,管理大小限制、扩展名白名单、路径规则、MIME 类型和 URL 拼接。 +- `AvatarImageProcessor`:头像图片处理器,上传前把图片压缩为 JPEG。 +- `RealNameImageProcessor`:实名认证证件图片处理器,上传前把证件图压缩为 JPEG。 +- `RemoteImage`:Kingfisher 网络图片组件,统一远程图片加载、缓存和失败占位。 + +## 上传流程 + +1. 页面或 ViewModel 将用户选择的本地图片处理成上传数据。 +2. ViewModel 调用 `OSSUploadService` 的业务上传方法。 +3. `OSSUploadService` 调用 `UploadAPI.aliyunOSSBucket(bucket:)` 获取 STS 配置。 +4. `OSSUploadPolicy` 校验文件并生成 objectKey。 +5. `OSSUploadService` 使用 `AlibabaCloudOSS` SDK 上传数据。 +6. 上传成功后返回 `base_url + objectKey`。 +7. 业务 ViewModel 再把 URL 提交给对应业务接口。 + +## 路径规则 + +当前模块路径: +- `user_avatar`:`avatar/yyyyMMdd/scenicId/uuid_fileName` +- `real_name`:`real_name/yyyyMMdd/scenicId/uuid_fileName` +- `task_upload`:`task_upload/yyyyMMdd/scenicId/uuid_fileName` +- `cloud_driver`:`cloud_driver/yyyyMMdd/scenicId/uuid_fileName` +- `album_upload`:`album/yyyyMMdd/scenicId/uuid_fileName` +- `alive_album`:`live_albums/yyyyMMdd/scenicId/uuid_fileName` +- `punch_point`:`punch_point/yyyyMMdd/scenicId/uuid_fileName` +- `scenic_apply`:`scenic_apply/yyyyMMdd/scenicId/uuid_fileName` + +文件名会清理控制字符、`/` 和 `\`,避免生成非法 objectKey。 + +## 缓存边界 + +- OSS STS token 不落盘,只在一次上传流程中临时使用。 +- 原始图片 Data、压缩后图片 Data、上传进度不落盘。 +- 图片展示缓存交给 Kingfisher,业务代码不自行保存网络图片文件。 +- 正式登录 token 仍由 `SessionTokenStore` 使用 Keychain 保存,上传模块不直接读取或保存登录态。 diff --git a/suixinkan_ios/Core/Upload/UploadAPI.swift b/suixinkan_ios/Core/Upload/UploadAPI.swift new file mode 100644 index 0000000..710884d --- /dev/null +++ b/suixinkan_ios/Core/Upload/UploadAPI.swift @@ -0,0 +1,39 @@ +// +// UploadAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// OSS 配置服务协议,抽象 STS token 获取能力,便于上传服务测试替换。 +@MainActor +protocol OSSConfigServing { + /// 获取指定 bucket 的阿里云 OSS 临时上传配置。 + func aliyunOSSBucket(bucket: String) async throws -> AliyunOSSResponse +} + +@MainActor +/// 上传 API,封装文件上传前需要的服务端配置接口。 +final class UploadAPI { + private let client: APIClient + + /// 初始化上传 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取指定 bucket 的阿里云 OSS 临时上传配置。 + func aliyunOSSBucket(bucket: String = "vipsky") async throws -> AliyunOSSResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/config/get-sts-token", + queryItems: [URLQueryItem(name: "bucket", value: bucket)] + ) + ) + } +} + +extension UploadAPI: OSSConfigServing {} diff --git a/suixinkan_ios/Core/Upload/UploadImageProcessors.swift b/suixinkan_ios/Core/Upload/UploadImageProcessors.swift new file mode 100644 index 0000000..cc0f6e2 --- /dev/null +++ b/suixinkan_ios/Core/Upload/UploadImageProcessors.swift @@ -0,0 +1,121 @@ +// +// UploadImageProcessors.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation +import UIKit +import UniformTypeIdentifiers + +/// 头像图片处理器,负责压缩和规范化用户选择的头像图片。 +enum AvatarImageProcessor { + static let maxPixelLength: CGFloat = 1024 + static let jpegCompressionQuality: CGFloat = 0.82 + + /// 处理后的上传图片实体,包含数据、文件名和内容类型。 + struct ProcessedImage: Equatable { + let data: Data + let fileName: String + let contentType: UTType + } + + /// 将原始图片数据转换为适合头像上传的 JPEG 数据。 + static func process(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> ProcessedImage { + try UploadImageRenderer.process( + data: data, + fileNamePrefix: "avatar", + maxPixelLength: maxPixelLength, + jpegCompressionQuality: jpegCompressionQuality, + timestamp: timestamp + ) + } +} + +/// 实名证件图片处理器,负责压缩和规范化身份证正反面图片。 +enum RealNameImageProcessor { + static let maxPixelLength: CGFloat = 1800 + static let jpegCompressionQuality: CGFloat = 0.88 + + /// 将原始图片数据转换为适合实名认证上传的 JPEG 数据。 + static func process(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws -> AvatarImageProcessor.ProcessedImage { + try UploadImageRenderer.process( + data: data, + fileNamePrefix: "real_name_\(side.rawValue)", + maxPixelLength: maxPixelLength, + jpegCompressionQuality: jpegCompressionQuality, + timestamp: timestamp + ) + } +} + +/// 实名认证证件图片面向实体,区分人像面和国徽面。 +enum RealNameImageSide: String { + case front + case back +} + +/// 上传图片处理错误实体,表示图片无效或编码失败。 +enum UploadImageProcessingError: LocalizedError, Equatable { + case invalidImage + case encodingFailed + + var errorDescription: String? { + switch self { + case .invalidImage: + "请选择有效的图片" + case .encodingFailed: + "图片处理失败,请重新选择" + } + } +} + +/// 上传图片渲染器,提供通用图片缩放、铺底和 JPEG 编码能力。 +private enum UploadImageRenderer { + /// 将原始图片渲染为指定大小限制的 JPEG 上传数据。 + static func process( + data: Data, + fileNamePrefix: String, + maxPixelLength: CGFloat, + jpegCompressionQuality: CGFloat, + timestamp: TimeInterval + ) throws -> AvatarImageProcessor.ProcessedImage { + guard let image = UIImage(data: data), image.size.width > 0, image.size.height > 0 else { + throw UploadImageProcessingError.invalidImage + } + + let normalized = image.normalizedForUpload(maxPixelLength: maxPixelLength) + guard let jpegData = normalized.jpegData(compressionQuality: jpegCompressionQuality), !jpegData.isEmpty else { + throw UploadImageProcessingError.encodingFailed + } + + return AvatarImageProcessor.ProcessedImage( + data: jpegData, + fileName: "\(fileNamePrefix)_\(Int(timestamp)).jpg", + contentType: .jpeg + ) + } +} + +private extension UIImage { + /// 按最长边限制缩放图片,并用白色背景消除透明通道。 + func normalizedForUpload(maxPixelLength: CGFloat) -> UIImage { + let longestSide = max(size.width, size.height) + let scale = min(1, maxPixelLength / longestSide) + let targetSize = CGSize( + width: max(1, (size.width * scale).rounded()), + height: max(1, (size.height * scale).rounded()) + ) + + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = true + let renderer = UIGraphicsImageRenderer(size: targetSize, format: format) + return renderer.image { context in + UIColor.white.setFill() + context.fill(CGRect(origin: .zero, size: targetSize)) + draw(in: CGRect(origin: .zero, size: targetSize)) + } + } +} diff --git a/suixinkan_ios/Core/Upload/UploadModels.swift b/suixinkan_ios/Core/Upload/UploadModels.swift new file mode 100644 index 0000000..cf0b49a --- /dev/null +++ b/suixinkan_ios/Core/Upload/UploadModels.swift @@ -0,0 +1,100 @@ +// +// UploadModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 阿里云 OSS STS 配置响应实体,表示一次临时上传授权。 +struct AliyunOSSResponse: Decodable, Equatable { + let baseUrl: String + let endpoint: String + let region: String + let bucket: String + let expireSeconds: Int + let credentials: AliyunOSSCredentials + + /// 阿里云 OSS STS 配置响应 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case baseUrl = "base_url" + case endpoint + case region + case bucket + case expireSeconds = "expire_seconds" + case credentials + } + + /// 自定义解码,兼容后端字段类型不稳定的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + baseUrl = try container.decodeLossyString(forKey: .baseUrl) + endpoint = try container.decodeLossyString(forKey: .endpoint) + region = try container.decodeLossyString(forKey: .region) + bucket = try container.decodeLossyString(forKey: .bucket) + expireSeconds = try container.decodeLossyInt(forKey: .expireSeconds) ?? 0 + credentials = try container.decode(AliyunOSSCredentials.self, forKey: .credentials) + } +} + +/// 阿里云 OSS 临时凭证实体,表示 SDK 上传所需的访问密钥和安全 token。 +struct AliyunOSSCredentials: Decodable, Equatable { + let accessKeyId: String + let accessKeySecret: String + let securityToken: String + + /// 阿里云 OSS 临时凭证 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case accessKeyId = "access_key_id" + case accessKeySecret = "access_key_secret" + case securityToken = "security_token" + } + + /// 自定义解码,兼容后端字段类型不稳定的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + accessKeyId = try container.decodeLossyString(forKey: .accessKeyId) + accessKeySecret = try container.decodeLossyString(forKey: .accessKeySecret) + securityToken = try container.decodeLossyString(forKey: .securityToken) + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan_ios/Core/Validation/AppFormValidator.swift b/suixinkan_ios/Core/Validation/AppFormValidator.swift new file mode 100644 index 0000000..35c301a --- /dev/null +++ b/suixinkan_ios/Core/Validation/AppFormValidator.swift @@ -0,0 +1,74 @@ +// +// AppFormValidator.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 表单校验工具,承接排队设置等页面的通用数字和金额格式校验。 +enum AppFormValidator { + /// 规范化大陆手机号,去除所有空白字符。 + static func normalizedPhoneNumber(_ value: String) -> String { + value.filter { !$0.isWhitespace } + } + + /// 校验大陆 11 位手机号基础号段。 + static func isValidMainlandPhoneNumber(_ value: String) -> Bool { + let phone = normalizedPhoneNumber(value) + guard phone.count == 11, phone.first == "1" else { return false } + return phone.allSatisfy(\.isNumber) + } + + /// 校验整数文本在指定范围内。 + static func rangedInteger(_ text: String, name: String, range: ClosedRange) throws -> Int { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let value = Int(trimmed) else { + throw APIError.networkFailed("\(name)格式不正确") + } + guard range.contains(value) else { + throw APIError.networkFailed("\(name)须在 \(range.lowerBound)~\(range.upperBound)") + } + return value + } + + /// 校验两次通知阈值,第二次应小于等于第一次。 + static func validateQueueNoticeThresholds(first: Int, second: Int) throws { + guard second <= first else { + throw APIError.networkFailed("第二次通知阈值不能大于第一次通知阈值") + } + } + + /// 清洗最多指定小数位的金额输入。 + static func sanitizedMoneyInput(_ input: String, maxDecimalPlaces: Int = 2) -> String { + var result = "" + var hasDot = false + var decimalCount = 0 + + for char in input { + if char.isNumber { + if hasDot { + guard decimalCount < maxDecimalPlaces else { continue } + decimalCount += 1 + } + result.append(char) + } else if char == ".", !hasDot { + hasDot = true + result.append(char) + } + } + + if result.hasPrefix(".") { + result = "0" + result + } + return result + } + + /// 规范化提交用金额,最多两位小数。 + static func normalizedMoneyForSubmit(_ input: String) -> String { + let sanitized = sanitizedMoneyInput(input, maxDecimalPlaces: 2) + guard !sanitized.isEmpty, sanitized != "." else { return "" } + return sanitized + } +} diff --git a/suixinkan_ios/Features/Account/API/AccountContextAPI.swift b/suixinkan_ios/Features/Account/API/AccountContextAPI.swift new file mode 100644 index 0000000..bb30234 --- /dev/null +++ b/suixinkan_ios/Features/Account/API/AccountContextAPI.swift @@ -0,0 +1,76 @@ +// +// AccountContextAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 账号上下文服务协议,抽象权限、景区、门店和景点读取能力以便测试替换。 +protocol AccountContextServing { + /// 获取当前账号的角色权限列表。 + func rolePermissions() async throws -> [RolePermissionResponse] + + /// 获取当前账号可访问的景区列表。 + func scenicListAll() async throws -> ScenicListAllResponse + + /// 获取当前账号可访问的门店列表。 + func storeAll() async throws -> ListPayload + + /// 获取指定景区下的景点或打卡点列表。 + func scenicSpotListAll(scenicId: Int) async throws -> ListPayload +} + +@MainActor +/// 账号上下文 API,封装角色权限、景区、门店和景点读取接口。 +final class AccountContextAPI: AccountContextServing { + private let client: APIClient + + /// 初始化账号上下文 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取当前账号的角色权限列表。 + func rolePermissions() async throws -> [RolePermissionResponse] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/role-permission" + ) + ) + } + + /// 获取当前账号可访问的景区列表。 + func scenicListAll() async throws -> ScenicListAllResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic/list-all" + ) + ) + } + + /// 获取当前账号可访问的门店列表。 + func storeAll() async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/all" + ) + ) + } + + /// 获取指定景区下的景点或打卡点列表。 + func scenicSpotListAll(scenicId: Int) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic-spot/list-all", + queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")] + ) + ) + } +} diff --git a/suixinkan_ios/Features/Account/Account.md b/suixinkan_ios/Features/Account/Account.md new file mode 100644 index 0000000..8ae4a93 --- /dev/null +++ b/suixinkan_ios/Features/Account/Account.md @@ -0,0 +1,36 @@ +# Account 模块业务逻辑 + +## 模块职责 + +Account 模块负责同步账号进入业务页前必须具备的上下文数据,包括角色权限、景区、门店和当前景区下的景点/打卡点。 + +该模块只做读取和上下文装配,不负责登录 token 存储,也不负责页面导航。 + +## 数据边界 + +- 角色权限:由 `/api/yf-handset-app/role-permission` 返回,写入 `PermissionContext`。 +- 景区列表:由 `/api/yf-handset-app/photog/scenic/list-all` 返回,用于账号业务作用域。 +- 门店列表:由 `/api/app/store/all` 返回,并通过 `scenic_id` 关联景区。 +- 景点/打卡点:由 `/api/yf-handset-app/photog/scenic-spot/list-all` 按当前景区懒加载。 + +## 加载流程 + +登录成功或冷启动恢复时,`AccountContextLoader` 会并行请求用户资料和角色权限。权限成功后继续读取景区和门店: + +1. 用户资料刷新 `AccountContext.profile`。 +2. 角色权限刷新 `PermissionContext`,并计算当前角色的权限 URI 集合。 +3. 景区列表失败时,从角色权限里的景区数据去重兜底。 +4. 门店列表失败时不阻断登录,只写入空门店列表。 +5. 当前景区确定后,`ScenicSpotContext` 再按景区 ID 拉取景点/打卡点。 + +## 切换规则 + +- 切换角色时,当前景区优先保留在新角色可访问景区中,否则选择新角色第一个景区。 +- 切换景区时,当前门店优先保留同景区门店,否则选择该景区第一个门店。 +- 切换景区后,景点/打卡点列表会重新加载。 + +## 缓存规则 + +`AccountSnapshotStore` 只保存非敏感上下文快照,包括当前角色 ID、当前景区 ID、当前门店 ID 和基础账号展示信息。 + +景点/打卡点列表只保存在内存中,不长期落盘。后续业务需要离线缓存时,需要按账号类型、业务账号 ID 和景区 ID 做隔离。 diff --git a/suixinkan_ios/Features/Account/Models/AccountContextModels.swift b/suixinkan_ios/Features/Account/Models/AccountContextModels.swift new file mode 100644 index 0000000..bae8078 --- /dev/null +++ b/suixinkan_ios/Features/Account/Models/AccountContextModels.swift @@ -0,0 +1,355 @@ +// +// AccountContextModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 角色权限响应实体,表示一个角色、该角色权限树以及可访问景区。 +struct RolePermissionResponse: Decodable, Equatable { + let role: RoleInfo + let scenic: [ScenicInfo] +} + +/// 角色实体,表示用户可切换的业务角色及其权限树。 +struct RoleInfo: Decodable, Equatable, Identifiable { + let id: Int + let name: String + let notes: String? + let permission: [PermissionItem] + + /// 角色响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + case notes + case permission + } + + /// 创建角色实体,主要用于测试和本地状态恢复。 + init(id: Int, name: String, notes: String? = nil, permission: [PermissionItem] = []) { + self.id = id + self.name = name + self.notes = notes + self.permission = permission + } + + /// 自定义解码,兼容 ID 类型不稳定和权限树缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + notes = try container.decodeIfPresent(String.self, forKey: .notes) + permission = try container.decodeIfPresent([PermissionItem].self, forKey: .permission) ?? [] + } +} + +/// 权限节点实体,表示一个菜单或功能权限,并可递归包含子权限。 +struct PermissionItem: Decodable, Equatable, Identifiable { + let id: Int + let pid: Int + let name: String + let uri: String + let iconSrc: String? + let children: [PermissionItem] + + /// 权限节点响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case pid + case name + case uri + case iconSrc = "icon_src" + case children + } + + /// 创建权限节点,主要用于测试和本地构造权限树。 + init(id: Int, pid: Int = 0, name: String, uri: String = "", iconSrc: String? = nil, children: [PermissionItem] = []) { + self.id = id + self.pid = pid + self.name = name + self.uri = uri + self.iconSrc = iconSrc + self.children = children + } + + /// 自定义解码,兼容 ID 类型不稳定和 children 缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + pid = try container.decodeLossyInt(forKey: .pid) ?? 0 + name = try container.decodeLossyString(forKey: .name) + uri = try container.decodeLossyString(forKey: .uri) + iconSrc = try container.decodeIfPresent(String.self, forKey: .iconSrc) + children = try container.decodeIfPresent([PermissionItem].self, forKey: .children) ?? [] + } +} + +/// 景区权限实体,表示某个角色可访问的景区。 +struct ScenicInfo: Decodable, Equatable, Identifiable { + let id: Int + let name: String + let status: Int? + let location: ScenicLocationInfo? + let coverImg: String? + + /// 景区权限响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + case status + case location + case coverImg = "cover_img" + } + + /// 创建景区权限实体,主要用于测试和兜底列表生成。 + init(id: Int, name: String, status: Int? = nil, location: ScenicLocationInfo? = nil, coverImg: String? = nil) { + self.id = id + self.name = name + self.status = status + self.location = location + self.coverImg = coverImg + } + + /// 自定义解码,兼容 location 是对象或 JSON 字符串。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + status = try container.decodeLossyInt(forKey: .status) + coverImg = try container.decodeIfPresent(String.self, forKey: .coverImg) + if let value = try? container.decodeIfPresent(ScenicLocationInfo.self, forKey: .location) { + location = value + } else if let rawValue = try? container.decodeIfPresent(String.self, forKey: .location), + let data = rawValue.data(using: .utf8) { + location = try? JSONDecoder().decode(ScenicLocationInfo.self, from: data) + } else { + location = nil + } + } +} + +/// 景区位置实体,表示景区经纬度和地址。 +struct ScenicLocationInfo: Decodable, Equatable { + let lng: Double + let lat: Double + let address: String + + /// 景区位置响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case lng + case lat + case address + } + + /// 创建景区位置实体,主要用于测试和本地构造。 + init(lng: Double, lat: Double, address: String) { + self.lng = lng + self.lat = lat + self.address = address + } + + /// 自定义解码,兼容经纬度是字符串或数字。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + lng = try container.decodeLossyDouble(forKey: .lng) ?? 0 + lat = try container.decodeLossyDouble(forKey: .lat) ?? 0 + address = try container.decodeLossyString(forKey: .address) + } +} + +/// 景区列表响应实体,表示用户可选择的景区列表。 +struct ScenicListAllResponse: Decodable, Equatable { + let total: Int + let list: [ScenicListItem] + + /// 景区列表响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case total + case list + } + + /// 创建景区列表响应,主要用于测试和兜底数据。 + init(total: Int, list: [ScenicListItem]) { + self.total = total + self.list = list + } + + /// 自定义解码,兼容 total 类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + list = try container.decodeIfPresent([ScenicListItem].self, forKey: .list) ?? [] + } +} + +/// 景区列表项实体,表示一个可进入的景区。 +struct ScenicListItem: Decodable, Equatable, Identifiable { + let id: Int + let name: String + + /// 景区列表项响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + } + + /// 创建景区列表项,主要用于测试和本地构造。 + init(id: Int, name: String) { + self.id = id + self.name = name + } + + /// 自定义解码,兼容 ID 类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + } +} + +/// 门店实体,表示一个景区下可进入的门店。 +struct StoreItem: Decodable, Equatable, Identifiable { + let id: Int + let scenicId: Int + let name: String + let address: String + let status: Int + let statusText: String + let tel: String? + let logo: String? + + /// 门店响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case scenicId = "scenic_id" + case name + case address + case status + case statusText = "status_text" + case tel + case logo + } + + /// 创建门店实体,主要用于测试和本地构造。 + init( + id: Int, + scenicId: Int, + name: String, + address: String = "", + status: Int = 0, + statusText: String = "", + tel: String? = nil, + logo: String? = nil + ) { + self.id = id + self.scenicId = scenicId + self.name = name + self.address = address + self.status = status + self.statusText = statusText + self.tel = tel + self.logo = logo + } + + /// 自定义解码,兼容字段类型不稳定和可选字段缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 + name = try container.decodeLossyString(forKey: .name) + address = try container.decodeLossyString(forKey: .address) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusText = try container.decodeLossyString(forKey: .statusText) + tel = try container.decodeIfPresent(String.self, forKey: .tel) + logo = try container.decodeIfPresent(String.self, forKey: .logo) + } +} + +/// 景点实体,表示某个景区下的景点或打卡点。 +struct ScenicSpotItem: Decodable, Equatable, Identifiable { + let id: Int + let name: String + let status: Int + let statusLabel: String + + /// 景点响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + case status + case statusLabel = "status_label" + } + + /// 创建景点实体,主要用于测试和本地构造。 + init(id: Int, name: String, status: Int = 0, statusLabel: String = "") { + self.id = id + self.name = name + self.status = status + self.statusLabel = statusLabel + } + + /// 自定义解码,兼容字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + name = try container.decodeLossyString(forKey: .name) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusLabel = try container.decodeLossyString(forKey: .statusLabel) + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } + + /// 将 Double、Int 或数字字符串宽松解码为浮点数。 + func decodeLossyDouble(forKey key: Key) throws -> Double? { + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} diff --git a/suixinkan_ios/Features/Assets/API/AssetsAPI.swift b/suixinkan_ios/Features/Assets/API/AssetsAPI.swift new file mode 100644 index 0000000..d3b54c4 --- /dev/null +++ b/suixinkan_ios/Features/Assets/API/AssetsAPI.swift @@ -0,0 +1,373 @@ +// +// AssetsAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 资产服务协议,抽象云盘、媒体库和相册接口以便 ViewModel 测试替换。 +@MainActor +protocol AssetsServing { + /// 获取云盘文件列表。 + func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload + + /// 创建云盘文件夹。 + func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws + + /// 登记已上传到 OSS 的云盘文件。 + func cloudFileUpload(_ request: CloudFileUploadRequest) async throws + + /// 删除云盘文件或文件夹。 + func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws + + /// 移动云盘文件或文件夹。 + func cloudFileMove(_ request: CloudFileMoveRequest) async throws + + /// 重命名云盘文件夹。 + func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws + + /// 重命名云盘文件。 + func cloudFileEdit(_ request: CloudFileModifyRequest) async throws + + /// 检查当前账号是否允许上传云盘文件。 + func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse + + /// 获取素材或样片列表。 + func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse + + /// 获取素材或样片详情。 + func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail + + /// 更新素材或样片上下架状态。 + func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws + + /// 删除素材或样片。 + func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws + + /// 新增素材标签。 + func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws + + /// 上传素材或样片。 + func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws + + /// 编辑素材。 + func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws + + /// 获取样片上传可关联的项目列表。 + func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload + + /// 获取相册文件夹列表。 + func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload + + /// 获取相册文件夹详情。 + func albumFolderInfo(id: Int) async throws -> AlbumFolderItem + + /// 获取相册内文件列表。 + func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload + + /// 新建相册文件夹。 + func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws + + /// 编辑相册文件夹名称、备注或封面。 + func editAlbumFolder(folderId: Int, coverFileId: Int?, name: String?, remark: String?) async throws + + /// 删除相册内文件。 + func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws + + /// 登记已上传到 OSS 的相册文件。 + func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws +} + +/// 资产 API,负责封装相册云盘和素材管理相关接口。 +@MainActor +final class AssetsAPI: AssetsServing { + private let client: APIClient + + /// 初始化资产 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取云盘文件列表。 + func cloudFileList( + parentFolderId: Int, + name: String = "", + type: Int = 0, + orderBy: Int = 2, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/cloud-driver/list", + queryItems: [ + URLQueryItem(name: "parent_folder_id", value: "\(parentFolderId)"), + URLQueryItem(name: "name", value: name), + URLQueryItem(name: "type", value: "\(type)"), + URLQueryItem(name: "order_by", value: "\(orderBy)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } + + /// 创建云盘文件夹。 + func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-create", body: request) + ) as EmptyPayload + } + + /// 登记已上传到 OSS 的云盘文件。 + func cloudFileUpload(_ request: CloudFileUploadRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-upload", body: request) + ) as EmptyPayload + } + + /// 删除云盘文件或文件夹。 + func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/delete", body: request) + ) as EmptyPayload + } + + /// 移动云盘文件或文件夹。 + func cloudFileMove(_ request: CloudFileMoveRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/move", body: request) + ) as EmptyPayload + } + + /// 重命名云盘文件夹。 + func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/folder-edit", body: request) + ) as EmptyPayload + } + + /// 重命名云盘文件。 + func cloudFileEdit(_ request: CloudFileModifyRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/cloud-driver/file-edit", body: request) + ) as EmptyPayload + } + + /// 检查当前账号是否允许上传云盘文件。 + func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse { + try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/cloud-driver/check-upload-permission") + ) + } + + /// 获取素材或样片列表。 + func mediaAlbumList( + kind: MediaLibraryKind = .material, + keyword: String = "", + status: Int? = nil, + page: Int = 1, + pageSize: Int = 10 + ) async throws -> MediaLibraryListResponse { + var query = [ + URLQueryItem(name: "type", value: "\(kind.rawValue)"), + URLQueryItem(name: "keyword", value: keyword), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let status, status >= 0 { + query.append(URLQueryItem(name: "status", value: "\(status)")) + } + return try await client.send( + APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: query) + ) + } + + /// 获取素材或样片详情。 + func mediaAlbumDetail(id: Int, kind: MediaLibraryKind = .material) async throws -> MediaLibraryDetail { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/media-album/detail", + queryItems: [ + URLQueryItem(name: "id", value: "\(id)"), + URLQueryItem(name: "type", value: "\(kind.rawValue)") + ] + ) + ) + } + + /// 更新素材或样片上下架状态。 + func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/app/media-album/operation", body: request) + ) as EmptyPayload + } + + /// 删除素材或样片。 + func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/app/media-album/delete", body: request) + ) as EmptyPayload + } + + /// 新增素材标签。 + func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/app/media-album/add-tag", body: request) + ) as EmptyPayload + } + + /// 上传素材或样片。 + func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/app/media-album/upload", body: request) + ) as EmptyPayload + } + + /// 编辑素材。 + func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/app/media-album/edit", body: request) + ) as EmptyPayload + } + + /// 获取样片上传可关联的项目列表。 + func projectList( + scenicId: Int, + name: String? = nil, + page: Int = 1, + pageSize: Int = 50 + ) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let name, !name.isEmpty { + query.append(URLQueryItem(name: "name", value: name)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: query) + ) + } + + /// 获取相册文件夹列表。 + func albumFolderList( + scenicId: Int, + page: Int = 1, + pageSize: Int = 10, + name: String = "", + startTime: String? = nil, + endTime: String? = nil + ) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"), + URLQueryItem(name: "cloud_folder_type", value: "1") + ] + if !name.isEmpty { + query.append(URLQueryItem(name: "name", value: name)) + } + if let startTime, !startTime.isEmpty { + query.append(URLQueryItem(name: "start_time", value: startTime)) + } + if let endTime, !endTime.isEmpty { + query.append(URLQueryItem(name: "end_time", value: endTime)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/album/folder-list", queryItems: query) + ) + } + + /// 获取相册文件夹详情。 + func albumFolderInfo(id: Int) async throws -> AlbumFolderItem { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/album/folder-info", + queryItems: [URLQueryItem(name: "id", value: "\(id)")] + ) + ) + } + + /// 获取相册内文件列表。 + func albumFileList( + scenicId: Int, + folderId: Int, + fileType: Int, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/album/file-list", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "folder_id", value: "\(folderId)"), + URLQueryItem(name: "file_type", value: "\(fileType)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } + + /// 新建相册文件夹。 + func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws { + _ = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/album/folder-add", + body: AlbumFolderAddRequest( + scenicId: "\(scenicId)", + name: name, + remark: remark, + cloudFolderType: 1 + ) + ) + ) as EmptyPayload + } + + /// 编辑相册文件夹名称、备注或封面。 + func editAlbumFolder(folderId: Int, coverFileId: Int? = nil, name: String? = nil, remark: String? = nil) async throws { + _ = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/album/folder-edit", + body: AlbumFolderEditRequest(folderId: folderId, coverFileId: coverFileId, name: name, remark: remark) + ) + ) as EmptyPayload + } + + /// 删除相册内文件。 + func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws { + _ = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/album/file-delete", + body: AlbumFileDeleteRequest(idList: idList, folderId: folderId) + ) + ) as EmptyPayload + } + + /// 登记已上传到 OSS 的相册文件。 + func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws { + _ = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/album/file-upload-url", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "file_url", value: fileURL), + URLQueryItem(name: "folder_id", value: "\(folderId)") + ] + ) + ) as EmptyPayload + } +} diff --git a/suixinkan_ios/Features/Assets/Assets.md b/suixinkan_ios/Features/Assets/Assets.md new file mode 100644 index 0000000..34ca0e9 --- /dev/null +++ b/suixinkan_ios/Features/Assets/Assets.md @@ -0,0 +1,90 @@ +# Assets 模块业务逻辑 + +## 模块职责 + +Assets 模块负责首页中的相册、相册云盘、素材管理和样片管理入口。 + +- `album_list` 进入 `AlbumListView`。 +- `album_trailer` 进入 `AlbumTrailerEntryView`。 +- `cloud_management` 进入 `CloudStorageView`。 +- `cloud_storage_transit` 进入 `CloudStorageTransitView`。 +- `asset_management` 进入 `MediaLibraryView(kind: .material)`。 +- `material_upload` 进入 `MediaLibraryUploadView`。 +- `sample_management` 进入 `MediaLibraryView(kind: .sample)`。 +- `sample_upload` 进入 `MediaLibraryUploadView(kind: .sample)`。 + +样片复用媒体库列表、详情、上下架、删除和 OSS 上传链路;上传样片额外要求选择关联项目。 + +## 相册管理逻辑 + +`AlbumListViewModel` 只保存相册列表、搜索条件、日期筛选、分页和创建状态。缺少当前景区时清空列表并停止请求接口。 + +相册列表流程: + +1. 页面从 `AccountContext.currentScenic` 读取当前景区 ID。 +2. 调用 `albumFolderList` 获取 `cloud_folder_type = 1` 的相册文件夹。 +3. 支持按相册名称、开始时间、结束时间筛选。 +4. 新建相册调用 `addAlbumFolder`,成功后刷新第一页。 + +`AlbumDetailViewModel` 管理单个相册的信息和文件列表。图片和视频通过 `AlbumFileTab` 区分,图片使用 `file_type = 2`,视频使用 `file_type = 1`。详情页支持编辑相册名称、编辑备注、设置封面和删除相册文件,操作成功后刷新详情。 + +相册文件预览使用 `RemoteImage` 展示图片,视频使用系统 `VideoPlayer` 播放。 + +## 相册预览上传逻辑 + +`AlbumTrailerViewModel` 管理相册选择、本地图片/视频选择、上传进度和提交状态。 + +上传流程: + +1. 页面加载当前景区下的相册列表。 +2. 用户通过 `PhotosPicker` 选择图片或视频。 +3. 提交时先调用 `OSSUploadService.uploadAlbumFile` 上传到 OSS。 +4. 上传成功后调用 `albumFileUploadURL` 把文件 URL 写入相册。 +5. 任一文件上传失败时停止后续入库,并保留错误提示。 + +本地文件数据、OSS STS 和上传进度只保存在当前上传流程内,不落盘。 + +## 云盘逻辑 + +`CloudStorageViewModel` 只保存云盘模块内状态,包括目录路径、文件列表、筛选、排序、分页和当前操作状态。 + +云盘上传流程: + +1. 页面通过 `PhotosPicker` 选择图片或视频。 +2. `CloudStorageViewModel` 调用 `checkCloudUploadPermission`。 +3. 权限通过后调用 `OSSUploadService.uploadCloudFile` 上传到 OSS。 +4. 上传成功后调用 `cloudFileUpload` 把文件 URL 写入云盘。 +5. 刷新当前目录列表。 + +下载文件只保存到 App 沙盒 `Documents/CloudDownloads`,不自动写入系统相册。上传/下载记录由 `CloudTransferStore` 保存,生命周期仅限本次 App 会话,不落盘。 + +## 素材/样片管理逻辑 + +`MediaLibraryViewModel` 按 `MediaLibraryKind` 管理素材或样片列表、关键词、审核状态、分页、详情、删除和上下架。素材接口使用 `type = 1`,样片接口使用 `type = 2`。 + +媒体库上下架规则: + +- 审核通过的素材或样片可以上下架。 +- 审核未通过或待审核的素材或样片禁止上下架。 +- 操作成功后刷新当前媒体库列表。 + +`MediaLibraryEditorViewModel` 管理上传和编辑表单。提交前会校验名称、当前景区、打卡点、封面、媒体文件和标签长度。样片上传还会加载当前景区下的项目列表,并强制选择关联项目。 + +素材/样片上传流程: + +1. 页面通过 `PhotosPicker` 选择封面和媒体文件。 +2. 打卡点来源于 `ScenicSpotContext.spots`。 +3. 样片上传从 `projectList` 获取可关联项目,提交时携带 `project_id`。 +4. 提交时先把封面和媒体文件上传 OSS。 +5. 再把最终 OSS URL、文件尺寸、文件大小、打卡点 ID、标签和项目 ID 提交给媒体库接口。 +6. 上传失败时不提交媒体库接口。 + +## 缓存边界 + +相册列表、相册文件列表、云盘文件列表、素材列表、样片列表、项目选择、上传进度、下载记录、OSS STS、本地文件数据和媒体库表单都不进入 `AppSession`、`AccountContext` 或 TabBar 状态。 + +图片缓存继续交给 Kingfisher 的 `RemoteImage`。业务模块不自行缓存远程图片文件或 `Data`。 + +## 测试要求 + +新增相册、云盘、素材或样片逻辑时,需要同步补充 API、ViewModel 和路由测试。测试不通过时先修复问题,再继续迁移后续功能。 diff --git a/suixinkan_ios/Features/Assets/Models/AssetsModels.swift b/suixinkan_ios/Features/Assets/Models/AssetsModels.swift new file mode 100644 index 0000000..61f13f4 --- /dev/null +++ b/suixinkan_ios/Features/Assets/Models/AssetsModels.swift @@ -0,0 +1,1021 @@ +// +// AssetsModels.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 云盘文件实体,表示相册云盘中的文件或文件夹。 +struct CloudDriveFile: Decodable, Identifiable, Hashable { + let id: Int + let parentFolderId: Int + let fileUrl: String + let coverUrl: String + let updatedAt: String + let name: String + let createdAt: String + let childNum: Int + let type: Int + let fileSize: Int64 + + /// 判断当前云盘项是否为文件夹。 + var isFolder: Bool { type == 99 } + + /// 判断当前云盘项是否为视频文件。 + var isVideo: Bool { + type == 1 || Self.hasVideoExtension(name) || Self.hasVideoExtension(fileUrl) + } + + /// 判断当前云盘项是否为图片文件。 + var isImage: Bool { + type == 2 || Self.hasImageExtension(name) || Self.hasImageExtension(fileUrl) + } + + /// 返回可用于预览的远程地址。 + var previewURLString: String { + coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl + } + + /// 根据文件扩展名判断是否是常见视频格式。 + private static func hasVideoExtension(_ value: String) -> Bool { + ["mp4", "mov", "m4v", "avi"].contains(pathExtension(for: value)) + } + + /// 根据文件扩展名判断是否是常见图片格式。 + private static func hasImageExtension(_ value: String) -> Bool { + ["png", "jpg", "jpeg", "heic", "heif", "webp"].contains(pathExtension(for: value)) + } + + /// 提取 URL 或本地路径中的扩展名。 + private static func pathExtension(for value: String) -> String { + URL(string: value)?.pathExtension.lowercased() ?? URL(fileURLWithPath: value).pathExtension.lowercased() + } + + /// 云盘文件字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case parentFolderId = "parent_folder_id" + case fileUrl = "file_url" + case coverUrl = "cover_url" + case updatedAt = "updated_at" + case name + case createdAt = "created_at" + case childNum = "child_num" + case type + case fileSize = "file_size" + } + + /// 构造本地路径根节点,供云盘面包屑维护当前目录。 + init(id: Int, name: String) { + self.id = id + parentFolderId = 0 + fileUrl = "" + coverUrl = "" + updatedAt = "" + self.name = name + createdAt = "" + childNum = 0 + type = 99 + fileSize = 0 + } + + /// 构造完整云盘文件实体,主要用于测试和本地状态拼装。 + init( + id: Int, + parentFolderId: Int, + fileUrl: String, + coverUrl: String = "", + updatedAt: String = "", + name: String, + createdAt: String = "", + childNum: Int = 0, + type: Int, + fileSize: Int64 = 0 + ) { + self.id = id + self.parentFolderId = parentFolderId + self.fileUrl = fileUrl + self.coverUrl = coverUrl + self.updatedAt = updatedAt + self.name = name + self.createdAt = createdAt + self.childNum = childNum + self.type = type + self.fileSize = fileSize + } + + /// 宽松解码云盘文件字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + parentFolderId = try container.decodeLossyInt(forKey: .parentFolderId) ?? 0 + fileUrl = try container.decodeLossyString(forKey: .fileUrl) + coverUrl = try container.decodeLossyString(forKey: .coverUrl) + updatedAt = try container.decodeLossyString(forKey: .updatedAt) + name = try container.decodeLossyString(forKey: .name) + createdAt = try container.decodeLossyString(forKey: .createdAt) + childNum = try container.decodeLossyInt(forKey: .childNum) ?? 0 + type = try container.decodeLossyInt(forKey: .type) ?? 0 + fileSize = try container.decodeLossyInt64(forKey: .fileSize) ?? 0 + } +} + +/// 云盘文件类型筛选实体,表示云盘页面的筛选范围。 +enum CloudDriveFilter: Int, CaseIterable, Identifiable { + case all = 0 + case video = 1 + case image = 2 + case folder = 99 + + var id: Int { rawValue } + + /// 返回筛选项展示文案。 + var title: String { + switch self { + case .all: + "全部" + case .video: + "视频" + case .image: + "图片" + case .folder: + "文件夹" + } + } +} + +/// 云盘排序实体,表示列表排序规则。 +enum CloudDriveSort: Int, CaseIterable, Identifiable { + case updatedDesc = 2 + case createdDesc = 1 + + var id: Int { rawValue } + + /// 返回排序项展示文案。 + var title: String { + switch self { + case .updatedDesc: + "最近更新" + case .createdDesc: + "创建时间" + } + } +} + +/// 云盘新建文件夹请求实体。 +struct CloudFolderCreateRequest: Encodable, Equatable { + let parentFolderId: Int + let name: String + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case parentFolderId = "parent_folder_id" + case name + } +} + +/// 云盘文件上传登记请求实体。 +struct CloudFileUploadRequest: Encodable, Equatable { + let parentFolderId: Int + let fileUrl: String + let fileName: String + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case parentFolderId = "parent_folder_id" + case fileUrl = "file_url" + case fileName = "file_name" + } +} + +/// 云盘批量操作项实体,表示移动或删除时传给后端的文件/文件夹。 +struct CloudFileActionItem: Encodable, Equatable { + let id: Int + let type: Int +} + +/// 云盘删除请求实体。 +struct CloudFileDeleteRequest: Encodable, Equatable { + let list: [CloudFileActionItem] +} + +/// 云盘移动请求实体。 +struct CloudFileMoveRequest: Encodable, Equatable { + let targetFolderId: Int + let list: [CloudFileActionItem] + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case targetFolderId = "target_folder_id" + case list + } +} + +/// 云盘文件夹重命名请求实体。 +struct CloudFolderModifyRequest: Encodable, Equatable { + let id: Int + let name: String +} + +/// 云盘文件重命名请求实体。 +struct CloudFileModifyRequest: Encodable, Equatable { + let id: Int + let fileName: String + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case fileName = "file_name" + } +} + +/// 云盘上传权限响应实体,表示当前账号是否允许继续上传。 +struct CheckCloudUploadResponse: Decodable, Equatable { + let canUpload: Bool + let reason: String + + /// 响应字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case canUpload = "can_upload" + case reason + } + + /// 宽松解码上传权限字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + canUpload = try container.decodeLossyBool(forKey: .canUpload) ?? true + reason = try container.decodeLossyString(forKey: .reason) + } +} + +/// 云盘本地待上传文件实体,表示 PhotosPicker 读取后的内存数据。 +struct CloudLocalUploadFile: Identifiable, Equatable { + let id = UUID() + let data: Data + let fileName: String + let fileType: Int +} + +/// 云盘传输方向实体,区分上传和下载记录。 +enum CloudTransferDirection: String, Codable, Equatable { + case upload + case download +} + +/// 云盘传输状态实体,表示一次上传或下载的阶段。 +enum CloudTransferStatus: String, Codable, Equatable { + case running + case success + case failed +} + +/// 云盘传输记录实体,仅保存在本次 App 会话内。 +struct CloudTransferItem: Identifiable, Equatable { + let id: UUID + let fileName: String + let direction: CloudTransferDirection + var progress: Int + var status: CloudTransferStatus + var message: String + let createdAt: Date + + /// 创建一条新的传输记录。 + init( + id: UUID = UUID(), + fileName: String, + direction: CloudTransferDirection, + progress: Int = 0, + status: CloudTransferStatus = .running, + message: String = "", + createdAt: Date = Date() + ) { + self.id = id + self.fileName = fileName + self.direction = direction + self.progress = progress + self.status = status + self.message = message + self.createdAt = createdAt + } +} + +/// 素材库类型实体,表示素材或样片两类媒体资产。 +enum MediaLibraryKind: Int, CaseIterable, Identifiable { + case material = 1 + case sample = 2 + + var id: Int { rawValue } + + /// 返回页面展示标题。 + var title: String { + switch self { + case .material: + "素材管理" + case .sample: + "样片管理" + } + } + + /// 返回上传页面标题。 + var uploadTitle: String { + switch self { + case .material: + "上传素材" + case .sample: + "上传样片" + } + } + + /// 返回详情页面标题。 + var detailTitle: String { + switch self { + case .material: + "素材详情" + case .sample: + "样片详情" + } + } + + /// 返回名称输入占位文案。 + var namePlaceholder: String { + switch self { + case .material: + "素材名称" + case .sample: + "样片名称" + } + } + + /// 返回搜索输入占位文案。 + var searchPlaceholder: String { + switch self { + case .material: + "搜索素材名称" + case .sample: + "搜索样片名称" + } + } + + /// 返回空列表标题。 + var emptyTitle: String { + switch self { + case .material: + "暂无素材" + case .sample: + "暂无样片" + } + } +} + +/// 素材审核状态筛选实体。 +enum MediaLibraryAuditFilter: Int, CaseIterable, Identifiable { + case all = -1 + case pending = 0 + case approved = 1 + case rejected = 2 + + var id: Int { rawValue } + + /// 返回筛选项展示文案。 + var title: String { + switch self { + case .all: + "全部" + case .pending: + "待审核" + case .approved: + "已通过" + case .rejected: + "未通过" + } + } +} + +/// 素材上下架状态实体。 +enum MediaListingStatus: Int, Codable, Equatable { + case offline = 0 + case online = 1 + + /// 返回切换后的目标状态。 + var toggled: MediaListingStatus { + self == .online ? .offline : .online + } + + /// 返回展示文案。 + var title: String { + self == .online ? "已上架" : "未上架" + } +} + +/// 素材库列表响应实体,包含分页列表和订单统计信息。 +struct MediaLibraryListResponse: Decodable, Equatable { + let total: Int + let list: [MediaLibraryItem] + let order: MediaLibraryOrderInfo? + + /// 响应字段映射。 + enum CodingKeys: String, CodingKey { + case total + case list + case order + } + + /// 宽松解码列表响应字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + list = (try? container.decodeIfPresent([MediaLibraryItem].self, forKey: .list)) ?? [] + order = try? container.decodeIfPresent(MediaLibraryOrderInfo.self, forKey: .order) + } +} + +/// 素材订单统计实体,表示素材库顶部统计卡片。 +struct MediaLibraryOrderInfo: Decodable, Equatable { + let totalNum: Int + let avgOrderAmount: Double + let refundTotal: Double + let avgChange: Double + + /// 响应字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case totalNum = "total_num" + case avgOrderAmount = "avg_order_amount" + case refundTotal = "refund_total" + case avgChange = "avg_change" + } + + /// 宽松解码统计字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + totalNum = try container.decodeLossyInt(forKey: .totalNum) ?? 0 + avgOrderAmount = try container.decodeLossyDouble(forKey: .avgOrderAmount) ?? 0 + refundTotal = try container.decodeLossyDouble(forKey: .refundTotal) ?? 0 + avgChange = try container.decodeLossyDouble(forKey: .avgChange) ?? 0 + } +} + +/// 素材库列表项实体,表示素材列表中的单个素材。 +struct MediaLibraryItem: Decodable, Identifiable, Hashable { + let id: Int + let name: String + let coverUrl: String + let createdAt: String + let status: Int + let downloadCount: Int + let likesCount: Int + let collectCount: Int + let shareCount: Int + let scenicSpotName: String + let projectName: String + let listingStatus: Int + + /// 判断素材是否通过审核。 + var isApproved: Bool { status == 1 } + + /// 当前素材上下架状态。 + var listing: MediaListingStatus { + MediaListingStatus(rawValue: listingStatus) ?? .offline + } + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case coverUrl = "cover_url" + case createdAt = "created_at" + case status + case downloadCount = "download_count" + case likesCount = "likes_count" + case collectCount = "collect_count" + case shareCount = "share_count" + case scenicSpotName = "scenic_spot_name" + case projectName = "project_name" + case listingStatus = "listing_status" + } + + /// 宽松解码素材列表项。 + 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) + coverUrl = try container.decodeLossyString(forKey: .coverUrl) + createdAt = try container.decodeLossyString(forKey: .createdAt) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + downloadCount = try container.decodeLossyInt(forKey: .downloadCount) ?? 0 + likesCount = try container.decodeLossyInt(forKey: .likesCount) ?? 0 + collectCount = try container.decodeLossyInt(forKey: .collectCount) ?? 0 + shareCount = try container.decodeLossyInt(forKey: .shareCount) ?? 0 + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + projectName = try container.decodeLossyString(forKey: .projectName) + listingStatus = try container.decodeLossyInt(forKey: .listingStatus) ?? 0 + } +} + +/// 素材详情实体,表示素材详情页展示的数据。 +struct MediaLibraryDetail: Decodable, Equatable { + let id: Int + let name: String + let coverUrl: String + let description: String + let status: Int + let listingStatus: Int + let likesCount: Int + let downloadCount: Int + let collectCount: Int + let shareCount: Int + let uploaderName: String + let createdAt: String + let mediaList: [MediaLibraryMediaItem] + let scenicName: String + let projectName: String + + /// 判断素材是否通过审核。 + var isApproved: Bool { status == 1 } + + /// 当前素材上下架状态。 + var listing: MediaListingStatus { + MediaListingStatus(rawValue: listingStatus) ?? .offline + } + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case coverUrl = "cover_url" + case description + case status + case listingStatus = "listing_status" + case likesCount = "likes_count" + case downloadCount = "download_count" + case collectCount = "collect_count" + case shareCount = "share_count" + case uploaderName = "uploader_name" + case createdAt = "created_at" + case mediaList = "media_list" + case scenicName = "scenic_name" + case projectName = "project_name" + } + + /// 宽松解码素材详情字段。 + 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) + coverUrl = try container.decodeLossyString(forKey: .coverUrl) + description = try container.decodeLossyString(forKey: .description) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + listingStatus = try container.decodeLossyInt(forKey: .listingStatus) ?? 0 + likesCount = try container.decodeLossyInt(forKey: .likesCount) ?? 0 + downloadCount = try container.decodeLossyInt(forKey: .downloadCount) ?? 0 + collectCount = try container.decodeLossyInt(forKey: .collectCount) ?? 0 + shareCount = try container.decodeLossyInt(forKey: .shareCount) ?? 0 + uploaderName = try container.decodeLossyString(forKey: .uploaderName) + createdAt = try container.decodeLossyString(forKey: .createdAt) + mediaList = (try? container.decodeIfPresent([MediaLibraryMediaItem].self, forKey: .mediaList)) ?? [] + scenicName = try container.decodeLossyString(forKey: .scenicName) + projectName = try container.decodeLossyString(forKey: .projectName) + } +} + +/// 素材媒体文件实体,表示详情页中的图片或视频资源。 +struct MediaLibraryMediaItem: Decodable, Identifiable, Hashable { + let id: Int + let originalName: String + let ossUrl: String + let thumbnailUrl: String + let type: Int + let size: Int64 + let showUrl: String + + /// 当前媒体是否为视频。 + var isVideo: Bool { + type == 1 || ["mp4", "mov", "m4v", "avi"].contains(URL(string: ossUrl)?.pathExtension.lowercased() ?? "") + } + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case originalName = "original_name" + case ossUrl = "oss_url" + case thumbnailUrl = "thumbnail_url" + case type + case size + case showUrl = "show_url" + } + + /// 宽松解码媒体文件字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + originalName = try container.decodeLossyString(forKey: .originalName) + ossUrl = try container.decodeLossyString(forKey: .ossUrl) + thumbnailUrl = try container.decodeLossyString(forKey: .thumbnailUrl) + type = try container.decodeLossyInt(forKey: .type) ?? 0 + size = try container.decodeLossyInt64(forKey: .size) ?? 0 + showUrl = try container.decodeLossyString(forKey: .showUrl) + } +} + +/// 素材上传文件尺寸实体。 +struct MediaAlbumFileSize: Codable, Equatable { + let width: Int + let height: Int +} + +/// 素材上传文件请求实体,表示上传到 OSS 后提交给服务端的媒体文件。 +struct MediaAlbumUploadItem: Codable, Equatable { + let originalName: String + let ossUrl: String + let size: Int64 + let fileWidthSize: MediaAlbumFileSize + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case originalName = "original_name" + case ossUrl = "oss_url" + case size + case fileWidthSize = "file_width_size" + } +} + +/// 素材上传请求实体。 +struct MediaAlbumUploadRequest: Encodable, Equatable { + let name: String + let type: Int + let mediaType: Int + let mediaList: [MediaAlbumUploadItem] + let coverUrl: String + let coverSize: MediaAlbumFileSize + let scenicSpotId: Int + let description: String + let materialTag: String + let projectId: Int + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case name + case type + case mediaType = "media_type" + case mediaList = "media_list" + case coverUrl = "cover_url" + case coverSize = "cover_size" + case scenicSpotId = "scenic_spot_id" + case description + case materialTag = "material_tag" + case projectId = "project_id" + } +} + +/// 素材编辑请求实体。 +struct MediaAlbumEditRequest: Encodable, Equatable { + let id: Int + let name: String + let type: Int + let mediaType: Int + let mediaList: [MediaAlbumUploadItem] + let coverUrl: String + let coverSize: MediaAlbumFileSize + let scenicSpotId: Int + let description: String + let materialTag: String + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case type + case mediaType = "media_type" + case mediaList = "media_list" + case coverUrl = "cover_url" + case coverSize = "cover_size" + case scenicSpotId = "scenic_spot_id" + case description + case materialTag = "material_tag" + } +} + +/// 素材操作请求实体,用于素材或样片上下架。 +struct MediaAlbumOperationRequest: Encodable, Equatable { + let id: Int + let type: Int + let listingStatus: Int + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case type + case listingStatus = "listing_status" + } +} + +/// 素材删除请求实体,用于删除素材或样片。 +struct MediaAlbumDeleteRequest: Encodable, Equatable { + let id: Int + let type: Int +} + +/// 素材标签新增请求实体。 +struct MediaAlbumAddTagRequest: Encodable, Equatable { + let name: String +} + +/// 素材本地待上传文件实体,表示封面或媒体文件的内存数据。 +struct MediaLocalUploadFile: Identifiable, Equatable { + let id = UUID() + let data: Data + let fileName: String + let fileType: Int + let width: Int + let height: Int +} + +/// 相册文件夹实体,表示相册管理列表中的一个相册。 +struct AlbumFolderItem: Decodable, Identifiable, Hashable { + let id: Int + let name: String + let coverFileId: Int + let countVideo: Int + let countImage: Int + let coverFile: AlbumFileItem? + let createTime: String + let remark: String + + /// 相册封面地址,优先使用封面文件的预览地址。 + var coverURLString: String { + coverFile?.previewURLString ?? "" + } + + /// 相册内文件总数。 + var totalCount: Int { + countVideo + countImage + } + + /// 字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case coverFileId = "cover_file_id" + case countVideo = "count_video" + case countImage = "count_image" + case coverFile = "cover" + case createTime = "created_at" + case remark + } + + /// 宽松解码相册文件夹字段。 + 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) + coverFileId = try container.decodeLossyInt(forKey: .coverFileId) ?? 0 + countVideo = try container.decodeLossyInt(forKey: .countVideo) ?? 0 + countImage = try container.decodeLossyInt(forKey: .countImage) ?? 0 + coverFile = try? container.decodeIfPresent(AlbumFileItem.self, forKey: .coverFile) + createTime = try container.decodeLossyString(forKey: .createTime) + remark = try container.decodeLossyString(forKey: .remark) + } +} + +/// 相册文件实体,表示相册详情中的图片或视频。 +struct AlbumFileItem: Decodable, Identifiable, Hashable { + let id: Int + let fileName: String + let fileType: Int + let fileUrl: String + let coverUrl: String + let fileSize: Int64 + let fileSizeHuman: String + let remark: String + + /// 判断当前文件是否为视频。 + var isVideo: Bool { + fileType == 1 || Self.hasVideoExtension(fileName) || Self.hasVideoExtension(fileUrl) + } + + /// 判断当前文件是否为图片。 + var isImage: Bool { + fileType == 2 || Self.hasImageExtension(fileName) || Self.hasImageExtension(fileUrl) + } + + /// 返回图片或视频封面的预览地址。 + var previewURLString: String { + coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl + } + + /// 返回文件大小展示文案。 + var displayFileSize: String { + if !fileSizeHuman.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return fileSizeHuman + } + return fileSize.assetsFormattedFileSize + } + + /// 根据文件扩展名判断是否为视频。 + private static func hasVideoExtension(_ value: String) -> Bool { + ["mp4", "mov", "m4v", "avi"].contains(pathExtension(for: value)) + } + + /// 根据文件扩展名判断是否为图片。 + private static func hasImageExtension(_ value: String) -> Bool { + ["png", "jpg", "jpeg", "heic", "heif", "webp"].contains(pathExtension(for: value)) + } + + /// 提取 URL 或文件路径中的扩展名。 + private static func pathExtension(for value: String) -> String { + URL(string: value)?.pathExtension.lowercased() ?? URL(fileURLWithPath: value).pathExtension.lowercased() + } + + /// 字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case fileName = "file_name" + case fileType = "file_type" + case fileUrl = "file_url" + case coverUrl = "cover_url" + case fileSize = "file_size" + case fileSizeHuman = "file_size_human" + case remark + } + + /// 宽松解码相册文件字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + fileName = try container.decodeLossyString(forKey: .fileName) + fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0 + fileUrl = try container.decodeLossyString(forKey: .fileUrl) + coverUrl = try container.decodeLossyString(forKey: .coverUrl) + fileSize = try container.decodeLossyInt64(forKey: .fileSize) ?? 0 + fileSizeHuman = try container.decodeLossyString(forKey: .fileSizeHuman) + remark = try container.decodeLossyString(forKey: .remark) + } +} + +/// 相册文件类型筛选实体,表示相册详情中的图片或视频 Tab。 +enum AlbumFileTab: Int, CaseIterable, Identifiable { + case image = 2 + case video = 1 + + var id: Int { rawValue } + + /// 返回页面展示标题。 + var title: String { + switch self { + case .image: + "图片" + case .video: + "视频" + } + } +} + +/// 新建相册请求实体。 +struct AlbumFolderAddRequest: Encodable, Equatable { + let scenicId: String + let name: String + let remark: String + let cloudFolderType: Int + + /// 请求字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case name + case remark + case cloudFolderType = "cloud_folder_type" + } +} + +/// 编辑相册请求实体,用于改名、备注和封面。 +struct AlbumFolderEditRequest: Encodable, Equatable { + let folderId: Int + let coverFileId: Int? + let name: String? + let remark: String? + + /// 请求字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case folderId = "id" + case coverFileId = "cover_file_id" + case name + case remark + } +} + +/// 删除相册文件请求实体。 +struct AlbumFileDeleteRequest: Encodable, Equatable { + let idList: [Int] + let folderId: Int + + /// 请求字段映射,兼容旧接口下划线命名。 + enum CodingKeys: String, CodingKey { + case idList = "id_list" + case folderId = "folder_id" + } +} + +/// 相册本地待上传文件实体,表示 PhotosPicker 读取后的图片或视频。 +struct AlbumLocalUploadFile: Identifiable, Equatable { + let id = UUID() + let data: Data + let fileName: String + let fileType: Int +} + +private extension Int64 { + /// 返回资产模块通用文件大小展示文案。 + var assetsFormattedFileSize: String { + let value = Double(self) + if value >= 1_073_741_824 { + return String(format: "%.1fGB", value / 1_073_741_824) + } + if value >= 1_048_576 { + return String(format: "%.1fMB", value / 1_048_576) + } + if value >= 1024 { + return String(format: "%.1fKB", value / 1024) + } + return "\(self)B" + } +} + +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 ? "1" : "0" + } + return "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// 将 String、Double 和 Int 宽松解码为 Int64。 + func decodeLossyInt64(forKey key: Key) throws -> Int64? { + if let value = try? decodeIfPresent(Int64.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return Int64(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int64(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Int64(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// 将 String、Int 和 Double 宽松解码为 Double。 + 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 + } + + /// 将 String、Int 和 Bool 宽松解码为 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 text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["1", "true", "yes"].contains(text) { return true } + if ["0", "false", "no"].contains(text) { return false } + } + return nil + } +} diff --git a/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift b/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift new file mode 100644 index 0000000..94e3a22 --- /dev/null +++ b/suixinkan_ios/Features/Assets/ViewControllers/AssetsViewControllers.swift @@ -0,0 +1,231 @@ +// +// AssetsViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 云盘列表页。 +final class CloudStorageViewController: ModuleTableViewController { + private let viewModel = CloudStorageViewModel() + + override func viewDidLoad() { + title = "云盘" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "传输", + style: .plain, + target: self, + action: #selector(openTransit) + ) + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.files.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let file = viewModel.files[indexPath.row] + cell.configure(title: file.name, subtitle: file.updatedAt, detail: "\(file.fileSize)") + } + + override func reloadContent() async { + await viewModel.reload(api: services.assetsAPI) + } + + override func willDisplayTableRow(at indexPath: IndexPath) { + guard indexPath.row >= viewModel.files.count - 2 else { return } + Task { await viewModel.loadMore(api: services.assetsAPI) } + } + + @objc private func openTransit() { + navigationController?.pushViewController(CloudStorageTransitViewController(), animated: true) + } +} + +extension CloudStorageViewModel: ViewModelBindable {} + +/// 云盘传输记录页。 +final class CloudStorageTransitViewController: ModuleTableViewController { + private let store = CloudTransferStore.shared + + override func viewDidLoad() { + title = "传输记录" + super.viewDidLoad() + store.onChange = { [weak self] in self?.reloadTable() } + } + + override func tableRowCount() -> Int { store.records.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let record = store.records[indexPath.row] + cell.configure(title: record.fileName, subtitle: record.message, detail: "\(record.progress)%") + } + + override func reloadContent() async {} +} + +enum MediaLibraryKindRoute { + case material + case sample +} + +/// 素材库 / 样片库列表页。 +final class MediaLibraryViewController: ModuleTableViewController { + private let kind: MediaLibraryKindRoute + private let viewModel: MediaLibraryViewModel + + init(kind: MediaLibraryKindRoute = .material) { + self.kind = kind + switch kind { + case .material: + viewModel = MediaLibraryViewModel(kind: .material) + case .sample: + viewModel = MediaLibraryViewModel(kind: .sample) + } + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + title = kind == .sample ? "样片库" : "素材库" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "上传", + style: .plain, + target: self, + action: #selector(openUpload) + ) + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.items.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + cell.configure(title: item.name, subtitle: item.projectName, detail: item.createdAt) + } + + override func reloadContent() async { + await viewModel.reload(api: services.assetsAPI) + } + + @objc private func openUpload() { + navigationController?.pushViewController(MediaLibraryUploadViewController(kind: kind), animated: true) + } +} + +extension MediaLibraryViewModel: ViewModelBindable {} + +/// 素材 / 样片上传页。 +final class MediaLibraryUploadViewController: ModuleTableViewController { + private let kind: MediaLibraryKindRoute + private let viewModel = MediaLibraryEditorViewModel() + private let nameField = UITextField() + + init(kind: MediaLibraryKindRoute) { + self.kind = kind + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + title = kind == .sample ? "上传样片" : "上传素材" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "提交", + style: .done, + target: self, + action: #selector(submit) + ) + super.viewDidLoad() + nameField.placeholder = "素材名称" + nameField.borderStyle = .roundedRect + nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) + tableView.tableHeaderView = nameField + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.projects.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let project = viewModel.projects[indexPath.row] + cell.configure(title: project.name, subtitle: project.statusName) + } + + override func reloadContent() async { + await viewModel.loadProjects(scenicId: services.currentScenicId, api: services.assetsAPI) + } + + @objc private func submit() { + viewModel.name = nameField.text ?? "" + Task { + let mediaKind: MediaLibraryKind = kind == .sample ? .sample : .material + let success = await viewModel.submit( + kind: mediaKind, + scenicId: services.currentScenicId, + api: services.assetsAPI, + uploadService: services.ossUploadService + ) + if success { navigationController?.popViewController(animated: true) } + } + } +} + +extension MediaLibraryEditorViewModel: ViewModelBindable {} + +/// 相册列表页。 +final class AlbumListViewController: ModuleTableViewController { + private let viewModel = AlbumListViewModel() + + override func viewDidLoad() { + title = "相册" + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.folders.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let folder = viewModel.folders[indexPath.row] + cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张") + } + + override func reloadContent() async { + await viewModel.reload(api: services.assetsAPI, scenicId: services.currentScenicId) + } +} + +extension AlbumListViewModel: ViewModelBindable {} + +/// 相册预告页。 +final class AlbumTrailerViewController: ModuleTableViewController { + private let viewModel = AlbumTrailerViewModel() + + override func viewDidLoad() { + title = "相册预告" + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.folders.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let folder = viewModel.folders[indexPath.row] + cell.configure(title: folder.name, subtitle: folder.createTime, detail: "\(folder.totalCount) 张") + } + + override func reloadContent() async { + await viewModel.loadFolders(api: services.assetsAPI, scenicId: services.currentScenicId) + } +} + +extension AlbumTrailerViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/Assets/ViewModels/AssetsViewModels.swift b/suixinkan_ios/Features/Assets/ViewModels/AssetsViewModels.swift new file mode 100644 index 0000000..f2e171b --- /dev/null +++ b/suixinkan_ios/Features/Assets/ViewModels/AssetsViewModels.swift @@ -0,0 +1,974 @@ +// +// AssetsViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 云盘传输记录仓库,保存本次 App 会话内的上传和下载进度。 +@MainActor +final class CloudTransferStore { + + var onChange: (() -> Void)? + static let shared = CloudTransferStore() + + private(set) var records: [CloudTransferItem] = [] { didSet { onChange?() } } + + /// 新增一条传输记录并返回记录 ID。 + @discardableResult + func start(fileName: String, direction: CloudTransferDirection) -> UUID { + let id = UUID() + records.insert(CloudTransferItem(id: id, fileName: fileName, direction: direction), at: 0) + return id + } + + /// 更新指定传输记录的进度。 + func update(id: UUID, progress: Int, message: String = "") { + guard let index = records.firstIndex(where: { $0.id == id }) else { return } + records[index].progress = max(0, min(100, progress)) + if !message.isEmpty { + records[index].message = message + } + } + + /// 标记指定传输记录成功。 + func succeed(id: UUID, message: String = "已完成") { + guard let index = records.firstIndex(where: { $0.id == id }) else { return } + records[index].progress = 100 + records[index].status = .success + records[index].message = message + } + + /// 标记指定传输记录失败。 + func fail(id: UUID, message: String) { + guard let index = records.firstIndex(where: { $0.id == id }) else { return } + records[index].status = .failed + records[index].message = message + } + + /// 清空当前会话传输记录。 + func clear() { + records = [] + } +} + +/// 云盘 ViewModel,负责文件列表、筛选、分页、文件操作和上传闭环。 +@MainActor +final class CloudStorageViewModel { + var onChange: (() -> Void)? + var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] { didSet { onChange?() } } + var files: [CloudDriveFile] = [] { didSet { onChange?() } } + var total = 0 { didSet { onChange?() } } + var page = 1 { didSet { onChange?() } } + var searchText = "" { didSet { onChange?() } } + var selectedFilter: CloudDriveFilter = .all { didSet { onChange?() } } + var selectedSort: CloudDriveSort = .updatedDesc { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var isMutating = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 20 + + /// 当前目录 ID。 + var currentFolderId: Int { + path.last?.id ?? 0 + } + + /// 判断当前目录是否还有下一页。 + var hasMore: Bool { + files.count < total + } + + /// 重新加载当前目录第一页。 + func reload(api: any AssetsServing) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let payload = try await requestFiles(api: api, page: 1) + page = 1 + files = payload.list + total = payload.total + } catch { + files = [] + total = 0 + page = 1 + errorMessage = error.localizedDescription + } + } + + /// 加载当前目录下一页。 + func loadMore(api: any AssetsServing) async { + guard hasMore, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let nextPage = page + 1 + let payload = try await requestFiles(api: api, page: nextPage) + page = nextPage + total = payload.total + files.append(contentsOf: payload.list) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 进入指定文件夹并刷新列表。 + func enterFolder(_ folder: CloudDriveFile, api: any AssetsServing) async { + guard folder.isFolder else { return } + path.append(folder) + await reload(api: api) + } + + /// 回到指定面包屑目录并刷新列表。 + func popToFolder(at index: Int, api: any AssetsServing) async { + guard path.indices.contains(index) else { return } + path = Array(path.prefix(index + 1)) + await reload(api: api) + } + + /// 创建文件夹并刷新当前目录。 + func createFolder(name: String, api: any AssetsServing) async -> Bool { + let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !folderName.isEmpty else { + errorMessage = "请输入文件夹名称" + return false + } + return await mutate(api: api) { + try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: currentFolderId, name: folderName)) + } + } + + /// 重命名文件或文件夹并刷新当前目录。 + func rename(_ item: CloudDriveFile, newName: String, api: any AssetsServing) async -> Bool { + let fileName = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !fileName.isEmpty else { + errorMessage = "请输入名称" + return false + } + return await mutate(api: api) { + if item.isFolder { + try await api.cloudFolderEdit(CloudFolderModifyRequest(id: item.id, name: fileName)) + } else { + try await api.cloudFileEdit(CloudFileModifyRequest(id: item.id, fileName: fileName)) + } + } + } + + /// 删除文件或文件夹并刷新当前目录。 + func delete(_ item: CloudDriveFile, api: any AssetsServing) async -> Bool { + await mutate(api: api) { + let action = CloudFileActionItem(id: item.id, type: item.type) + try await api.cloudFileDelete(CloudFileDeleteRequest(list: [action])) + } + } + + /// 移动文件或文件夹并刷新当前目录。 + func move(_ item: CloudDriveFile, targetFolderId: Int, api: any AssetsServing) async -> Bool { + await mutate(api: api) { + let action = CloudFileActionItem(id: item.id, type: item.type) + try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: targetFolderId, list: [action])) + } + } + + /// 上传本地文件到 OSS 后写入云盘。 + func upload( + localFiles: [CloudLocalUploadFile], + scenicId: Int?, + api: any AssetsServing, + uploadService: any OSSUploadServing, + transferStore: CloudTransferStore + ) async -> Bool { + guard let scenicId else { + errorMessage = "缺少当前景区,无法上传" + return false + } + guard !localFiles.isEmpty else { return true } + + isMutating = true + defer { isMutating = false } + + do { + let permission = try await api.checkCloudUploadPermission() + guard permission.canUpload else { + let reason = permission.reason.trimmingCharacters(in: .whitespacesAndNewlines) + errorMessage = reason.isEmpty ? "当前账号暂无上传权限" : reason + return false + } + + for file in localFiles { + let transferId = transferStore.start(fileName: file.fileName, direction: .upload) + do { + let url = try await uploadService.uploadCloudFile( + data: file.data, + fileName: file.fileName, + fileType: file.fileType, + scenicId: scenicId + ) { progress in + Task { @MainActor in + transferStore.update(id: transferId, progress: progress) + } + } + try await api.cloudFileUpload( + CloudFileUploadRequest(parentFolderId: currentFolderId, fileUrl: url, fileName: file.fileName) + ) + transferStore.succeed(id: transferId) + } catch { + transferStore.fail(id: transferId, message: error.localizedDescription) + throw error + } + } + await reload(api: api) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 执行一次云盘变更操作并刷新当前目录。 + private func mutate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool { + isMutating = true + defer { isMutating = false } + do { + try await operation() + await reload(api: api) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 组装并发送云盘文件列表请求。 + private func requestFiles(api: any AssetsServing, page: Int) async throws -> ListPayload { + try await api.cloudFileList( + parentFolderId: currentFolderId, + name: searchText.trimmingCharacters(in: .whitespacesAndNewlines), + type: selectedFilter.rawValue, + orderBy: selectedSort.rawValue, + page: page, + pageSize: pageSize + ) + } +} + +/// 媒体库 ViewModel,负责素材或样片列表、筛选、分页、详情和上下架。 +@MainActor +final class MediaLibraryViewModel { + var onChange: (() -> Void)? + let kind: MediaLibraryKind + var items: [MediaLibraryItem] = [] { didSet { onChange?() } } + var selectedDetail: MediaLibraryDetail? { didSet { onChange?() } } + var orderInfo: MediaLibraryOrderInfo? { didSet { onChange?() } } + var total = 0 { didSet { onChange?() } } + var page = 1 { didSet { onChange?() } } + var keyword = "" { didSet { onChange?() } } + var selectedAuditFilter: MediaLibraryAuditFilter = .all { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var isLoadingDetail = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 10 + + /// 初始化媒体库 ViewModel。 + init(kind: MediaLibraryKind = .material) { + self.kind = kind + } + + /// 判断当前列表是否还有下一页。 + var hasMore: Bool { + items.count < total + } + + /// 重新加载媒体库列表第一页。 + func reload(api: any AssetsServing) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let response = try await requestList(api: api, page: 1) + page = 1 + items = response.list + total = response.total + orderInfo = response.order + } catch { + items = [] + total = 0 + orderInfo = nil + page = 1 + errorMessage = error.localizedDescription + } + } + + /// 加载媒体库列表下一页。 + func loadMore(api: any AssetsServing) async { + guard hasMore, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let nextPage = page + 1 + let response = try await requestList(api: api, page: nextPage) + page = nextPage + total = response.total + orderInfo = response.order ?? orderInfo + items.append(contentsOf: response.list) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 加载媒体库详情,失败时保留已有详情或摘要。 + func loadDetail(id: Int, api: any AssetsServing) async { + isLoadingDetail = true + errorMessage = nil + defer { isLoadingDetail = false } + + do { + selectedDetail = try await api.mediaAlbumDetail(id: id, kind: kind) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 更新素材或样片上下架状态,审核未通过时禁止操作。 + func toggleListing(_ item: MediaLibraryItem, api: any AssetsServing) async -> Bool { + guard item.isApproved else { + errorMessage = "审核通过后才能上下架" + return false + } + return await operate(api: api) { + let target = item.listing.toggled + try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: item.id, type: kind.rawValue, listingStatus: target.rawValue)) + } + } + + /// 更新素材或样片详情页上下架状态,审核未通过时禁止操作。 + func toggleListing(detail: MediaLibraryDetail, api: any AssetsServing) async -> Bool { + guard detail.isApproved else { + errorMessage = "审核通过后才能上下架" + return false + } + return await operate(api: api) { + let target = detail.listing.toggled + try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: detail.id, type: kind.rawValue, listingStatus: target.rawValue)) + } + } + + /// 删除素材或样片并刷新列表。 + func delete(id: Int, api: any AssetsServing) async -> Bool { + await operate(api: api) { + try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: id, type: kind.rawValue)) + } + } + + /// 执行媒体库变更操作并刷新列表。 + private func operate(api: any AssetsServing, operation: () async throws -> Void) async -> Bool { + do { + try await operation() + await reload(api: api) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 组装并发送媒体库列表请求。 + private func requestList(api: any AssetsServing, page: Int) async throws -> MediaLibraryListResponse { + try await api.mediaAlbumList( + kind: kind, + keyword: keyword.trimmingCharacters(in: .whitespacesAndNewlines), + status: selectedAuditFilter.rawValue >= 0 ? selectedAuditFilter.rawValue : nil, + page: page, + pageSize: pageSize + ) + } +} + +/// 媒体库上传编辑 ViewModel,负责表单校验、OSS 上传和提交素材或样片。 +@MainActor +final class MediaLibraryEditorViewModel { + var onChange: (() -> Void)? + var name = "" { didSet { onChange?() } } + var description = "" { didSet { onChange?() } } + var selectedSpotId: Int? { didSet { onChange?() } } + var selectedProjectId: Int? { didSet { onChange?() } } + var projects: [PhotographerProjectItem] = [] { didSet { onChange?() } } + var tagsText = "" { didSet { onChange?() } } + var coverFile: MediaLocalUploadFile? { didSet { onChange?() } } + var mediaFiles: [MediaLocalUploadFile] = [] { didSet { onChange?() } } + var isSubmitting = false { didSet { onChange?() } } + var isLoadingProjects = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + var didSubmitSuccessfully = false { didSet { onChange?() } } + + private let editingId: Int? + + /// 初始化上传或编辑素材表单。 + init(detail: MediaLibraryDetail? = nil) { + editingId = detail?.id + name = detail?.name ?? "" + description = detail?.description ?? "" + } + + /// 判断当前是否处于编辑模式。 + var isEditing: Bool { + editingId != nil + } + + /// 加载样片上传可关联的项目列表。 + func loadProjects(scenicId: Int?, api: any AssetsServing) async { + guard let scenicId else { + projects = [] + errorMessage = "缺少当前景区,无法加载项目" + return + } + isLoadingProjects = true + errorMessage = nil + defer { isLoadingProjects = false } + + do { + let payload = try await api.projectList(scenicId: scenicId, name: nil, page: 1, pageSize: 50) + projects = payload.list + if let selectedProjectId, !projects.contains(where: { $0.id == selectedProjectId }) { + self.selectedProjectId = nil + } + } catch { + projects = [] + errorMessage = error.localizedDescription + } + } + + /// 添加封面本地文件。 + func setCover(_ file: MediaLocalUploadFile) { + coverFile = file + } + + /// 添加素材媒体文件。 + func addMediaFiles(_ files: [MediaLocalUploadFile]) { + mediaFiles.append(contentsOf: files) + } + + /// 移除指定媒体文件。 + func removeMediaFile(id: UUID) { + mediaFiles.removeAll { $0.id == id } + } + + /// 提交素材上传或编辑。 + func submit( + kind: MediaLibraryKind = .material, + scenicId: Int?, + api: any AssetsServing, + uploadService: any OSSUploadServing + ) async -> Bool { + guard validate(kind: kind, scenicId: scenicId) else { return false } + guard !isSubmitting else { return false } + isSubmitting = true + defer { isSubmitting = false } + + do { + let actualScenicId = scenicId ?? 0 + let coverUpload = try await uploadCover(scenicId: actualScenicId, uploadService: uploadService) + let uploads = try await uploadMediaFiles(scenicId: actualScenicId, uploadService: uploadService) + if let editingId { + let request = MediaAlbumEditRequest( + id: editingId, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + type: kind.rawValue, + mediaType: resolvedMediaType(), + mediaList: uploads, + coverUrl: coverUpload.url, + coverSize: coverUpload.size, + scenicSpotId: selectedSpotId ?? 0, + description: description, + materialTag: normalizedTags() + ) + try await api.mediaAlbumEdit(request) + } else { + let request = MediaAlbumUploadRequest( + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + type: kind.rawValue, + mediaType: resolvedMediaType(), + mediaList: uploads, + coverUrl: coverUpload.url, + coverSize: coverUpload.size, + scenicSpotId: selectedSpotId ?? 0, + description: description, + materialTag: normalizedTags(), + projectId: kind == .sample ? (selectedProjectId ?? 0) : 0 + ) + try await api.mediaAlbumUpload(request) + } + didSubmitSuccessfully = true + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 校验素材或样片表单。 + private func validate(kind: MediaLibraryKind, scenicId: Int?) -> Bool { + let title = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard scenicId != nil else { + errorMessage = "缺少当前景区,无法提交\(kind == .sample ? "样片" : "素材")" + return false + } + guard !title.isEmpty else { + errorMessage = "请输入\(kind == .sample ? "样片" : "素材")名称" + return false + } + guard selectedSpotId != nil else { + errorMessage = "请选择打卡点" + return false + } + guard kind != .sample || selectedProjectId != nil else { + errorMessage = "请选择关联项目" + return false + } + guard coverFile != nil else { + errorMessage = "请选择封面" + return false + } + guard !mediaFiles.isEmpty else { + errorMessage = "请选择\(kind == .sample ? "样片" : "素材")文件" + return false + } + guard normalizedTags().count <= 120 else { + errorMessage = "标签内容过长" + return false + } + return true + } + + /// 上传封面并返回 URL 和尺寸。 + private func uploadCover( + scenicId: Int, + uploadService: any OSSUploadServing + ) async throws -> (url: String, size: MediaAlbumFileSize) { + guard let coverFile else { + throw APIError.emptyData + } + let url = try await uploadService.uploadCloudFile( + data: coverFile.data, + fileName: coverFile.fileName, + fileType: coverFile.fileType, + scenicId: scenicId, + onProgress: { _ in } + ) + return (url, MediaAlbumFileSize(width: coverFile.width, height: coverFile.height)) + } + + /// 上传素材媒体文件并组装服务端请求项。 + private func uploadMediaFiles( + scenicId: Int, + uploadService: any OSSUploadServing + ) async throws -> [MediaAlbumUploadItem] { + var result: [MediaAlbumUploadItem] = [] + for file in mediaFiles { + let url = try await uploadService.uploadCloudFile( + data: file.data, + fileName: file.fileName, + fileType: file.fileType, + scenicId: scenicId, + onProgress: { _ in } + ) + result.append( + MediaAlbumUploadItem( + originalName: file.fileName, + ossUrl: url, + size: Int64(file.data.count), + fileWidthSize: MediaAlbumFileSize(width: file.width, height: file.height) + ) + ) + } + return result + } + + /// 根据已选文件推断素材媒体类型。 + private func resolvedMediaType() -> Int { + mediaFiles.contains { $0.fileType == 1 } ? 1 : 2 + } + + /// 规范化用户输入的标签文本。 + private func normalizedTags() -> String { + tagsText + .split { $0 == "," || $0 == "," || $0 == " " || $0 == "\n" } + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: ",") + } +} + +/// 相册列表 ViewModel,负责相册文件夹搜索、日期筛选、分页和新建相册。 +@MainActor +final class AlbumListViewModel { + var onChange: (() -> Void)? + var folders: [AlbumFolderItem] = [] { didSet { onChange?() } } + var total = 0 { didSet { onChange?() } } + var page = 1 { didSet { onChange?() } } + var searchText = "" { didSet { onChange?() } } + var startTime = "" { didSet { onChange?() } } + var endTime = "" { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var isMutating = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 10 + + /// 判断当前相册列表是否还有下一页。 + var hasMore: Bool { + folders.count < total + } + + /// 重新加载相册第一页。 + func reload(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId else { + folders = [] + total = 0 + page = 1 + errorMessage = "缺少当前景区,无法加载相册" + return + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let payload = try await requestFolders(api: api, scenicId: scenicId, page: 1) + self.page = 1 + folders = payload.list + total = payload.total + } catch { + folders = [] + total = 0 + page = 1 + errorMessage = error.localizedDescription + } + } + + /// 加载相册下一页。 + func loadMore(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId, hasMore, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let nextPage = page + 1 + let payload = try await requestFolders(api: api, scenicId: scenicId, page: nextPage) + page = nextPage + total = payload.total + folders.append(contentsOf: payload.list) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 新建相册并刷新列表。 + func createFolder(name: String, remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool { + let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard let scenicId else { + errorMessage = "缺少当前景区,无法创建相册" + return false + } + guard !folderName.isEmpty else { + errorMessage = "请输入相册名称" + return false + } + guard !isMutating else { return false } + + isMutating = true + defer { isMutating = false } + + do { + try await api.addAlbumFolder(scenicId: scenicId, name: folderName, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines)) + await reload(api: api, scenicId: scenicId) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 组装相册列表请求参数。 + private func requestFolders(api: any AssetsServing, scenicId: Int, page: Int) async throws -> ListPayload { + try await api.albumFolderList( + scenicId: scenicId, + page: page, + pageSize: pageSize, + name: searchText.trimmingCharacters(in: .whitespacesAndNewlines), + startTime: startTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty, + endTime: endTime.trimmingCharacters(in: .whitespacesAndNewlines).assetsNilIfEmpty + ) + } +} + +/// 相册详情 ViewModel,负责相册信息、图片/视频列表和文件操作。 +@MainActor +final class AlbumDetailViewModel { + var onChange: (() -> Void)? + let folderId: Int + var folder: AlbumFolderItem? { didSet { onChange?() } } + var files: [AlbumFileItem] = [] { didSet { onChange?() } } + var selectedTab: AlbumFileTab = .image { didSet { onChange?() } } + var total = 0 { didSet { onChange?() } } + var page = 1 { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var isMutating = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 20 + + /// 初始化相册详情 ViewModel。 + init(folderId: Int, summary: AlbumFolderItem? = nil) { + self.folderId = folderId + folder = summary + } + + /// 判断当前文件列表是否还有下一页。 + var hasMore: Bool { + files.count < total + } + + /// 重新加载相册信息和当前 Tab 文件列表。 + func reload(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId else { + files = [] + total = 0 + page = 1 + errorMessage = "缺少当前景区,无法加载相册内容" + return + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + folder = try await api.albumFolderInfo(id: folderId) + let listPayload = try await api.albumFileList( + scenicId: scenicId, + folderId: folderId, + fileType: selectedTab.rawValue, + page: 1, + pageSize: pageSize + ) + files = listPayload.list + total = listPayload.total + page = 1 + } catch { + page = 1 + errorMessage = error.localizedDescription + } + } + + /// 切换图片或视频列表并重新加载第一页。 + func selectTab(_ tab: AlbumFileTab, api: any AssetsServing, scenicId: Int?) async { + guard selectedTab != tab else { return } + selectedTab = tab + files = [] + total = 0 + page = 1 + await reload(api: api, scenicId: scenicId) + } + + /// 加载当前 Tab 下一页文件。 + func loadMore(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId, hasMore, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + do { + let nextPage = page + 1 + let payload = try await api.albumFileList( + scenicId: scenicId, + folderId: folderId, + fileType: selectedTab.rawValue, + page: nextPage, + pageSize: pageSize + ) + page = nextPage + total = payload.total + files.append(contentsOf: payload.list) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 删除相册文件并刷新当前列表。 + func delete(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool { + await mutate(api: api, scenicId: scenicId) { + try await api.deleteAlbumFiles(folderId: folderId, idList: [file.id]) + } + } + + /// 将指定相册文件设为封面。 + func setCover(file: AlbumFileItem, scenicId: Int?, api: any AssetsServing) async -> Bool { + await mutate(api: api, scenicId: scenicId) { + try await api.editAlbumFolder(folderId: folderId, coverFileId: file.id, name: nil, remark: nil) + } + } + + /// 更新相册名称。 + func rename(name: String, scenicId: Int?, api: any AssetsServing) async -> Bool { + let folderName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !folderName.isEmpty else { + errorMessage = "请输入相册名称" + return false + } + return await mutate(api: api, scenicId: scenicId) { + try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: folderName, remark: nil) + } + } + + /// 更新相册备注。 + func updateRemark(_ remark: String, scenicId: Int?, api: any AssetsServing) async -> Bool { + await mutate(api: api, scenicId: scenicId) { + try await api.editAlbumFolder(folderId: folderId, coverFileId: nil, name: nil, remark: remark.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + /// 执行相册变更操作并刷新详情。 + private func mutate(api: any AssetsServing, scenicId: Int?, operation: () async throws -> Void) async -> Bool { + guard !isMutating else { return false } + guard scenicId != nil else { + errorMessage = "缺少当前景区,无法操作相册" + return false + } + isMutating = true + defer { isMutating = false } + + do { + try await operation() + await reload(api: api, scenicId: scenicId) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } +} + +/// 相册预览上传 ViewModel,负责选择相册、本地文件上传和相册入库。 +@MainActor +final class AlbumTrailerViewModel { + var onChange: (() -> Void)? + var folders: [AlbumFolderItem] = [] { didSet { onChange?() } } + var selectedFolderId: Int? { didSet { onChange?() } } + var localFiles: [AlbumLocalUploadFile] = [] { didSet { onChange?() } } + var uploadProgress = 0 { didSet { onChange?() } } + var isLoadingFolders = false { didSet { onChange?() } } + var isSubmitting = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + var didUploadSuccessfully = false { didSet { onChange?() } } + + /// 加载可上传的相册列表。 + func loadFolders(api: any AssetsServing, scenicId: Int?) async { + guard let scenicId else { + folders = [] + selectedFolderId = nil + errorMessage = "缺少当前景区,无法加载相册" + return + } + + isLoadingFolders = true + errorMessage = nil + defer { isLoadingFolders = false } + + do { + let payload = try await api.albumFolderList( + scenicId: scenicId, + page: 1, + pageSize: 100, + name: "", + startTime: nil, + endTime: nil + ) + folders = payload.list + if selectedFolderId == nil { + selectedFolderId = folders.first?.id + } + } catch { + folders = [] + selectedFolderId = nil + errorMessage = error.localizedDescription + } + } + + /// 添加本地待上传文件。 + func addLocalFiles(_ files: [AlbumLocalUploadFile]) { + localFiles.append(contentsOf: files) + } + + /// 移除一个本地待上传文件。 + func removeLocalFile(id: UUID) { + localFiles.removeAll { $0.id == id } + } + + /// 上传本地文件到 OSS,并登记到指定相册。 + func submit(scenicId: Int?, api: any AssetsServing, uploadService: any OSSUploadServing) async -> Bool { + guard let scenicId else { + errorMessage = "缺少当前景区,无法上传" + return false + } + guard let folderId = selectedFolderId else { + errorMessage = "请选择相册" + return false + } + guard !localFiles.isEmpty else { + errorMessage = "请选择要上传的图片或视频" + return false + } + guard !isSubmitting else { return false } + + isSubmitting = true + didUploadSuccessfully = false + uploadProgress = 0 + defer { isSubmitting = false } + + do { + let fileCount = max(localFiles.count, 1) + for (index, file) in localFiles.enumerated() { + let url = try await uploadService.uploadAlbumFile( + data: file.data, + fileName: file.fileName, + fileType: file.fileType, + scenicId: scenicId + ) { progress in + Task { @MainActor in + let base = Double(index) / Double(fileCount) + let step = Double(progress) / Double(fileCount) + self.uploadProgress = min(99, Int((base + step / 100) * 100)) + } + } + try await api.albumFileUploadURL(scenicId: scenicId, fileURL: url, folderId: folderId) + } + uploadProgress = 100 + didUploadSuccessfully = true + localFiles = [] + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } +} + +private extension String { + /// 空字符串转 nil,方便可选筛选参数传递。 + var assetsNilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan_ios/Features/Auth/API/AuthAPI.swift b/suixinkan_ios/Features/Auth/API/AuthAPI.swift new file mode 100644 index 0000000..83f61ed --- /dev/null +++ b/suixinkan_ios/Features/Auth/API/AuthAPI.swift @@ -0,0 +1,42 @@ +// +// AuthAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +@MainActor +/// 登录认证 API,封装 v9 登录和账号选择相关接口。 +final class AuthAPI { + private let client: APIClient + + /// 初始化登录 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 使用手机号和密码发起 v9 登录,返回临时 token 及可选账号列表。 + func login(username: String, password: String) async throws -> V9AuthResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/app/v9/login", + body: LoginRequest(username: username, password: password) + ) + ) + } + + /// 选择具体景区或门店账号,换取正式登录 token。 + func setUser(_ request: SetUserRequest, tokenOverride: String? = nil) async throws -> V9AuthResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/app/v9/set-user", + body: request + ), + tokenOverride: tokenOverride + ) + } +} diff --git a/suixinkan_ios/Features/Auth/Auth.md b/suixinkan_ios/Features/Auth/Auth.md new file mode 100644 index 0000000..716d13a --- /dev/null +++ b/suixinkan_ios/Features/Auth/Auth.md @@ -0,0 +1,63 @@ +# Auth 模块业务逻辑 + +## 模块职责 + +Auth 模块负责登录页、手机号密码登录、多账号选择和账号选择后的正式登录确认。 + +该模块只处理登录流程本身: +- 表单输入、校验和协议确认。 +- 调用 v9 登录接口获取临时 token 和账号列表。 +- 单账号时自动调用 set-user。 +- 多账号时展示账号选择页。 +- 选择账号后调用 set-user 换取正式 token。 + +正式 token 存储、账号快照和全局登录态写入由 App 模块的 `AuthSessionCoordinator` 统一处理。 + +## 核心对象 + +- `LoginView`:登录页 UI,展示手机号、密码、协议确认和登录按钮。 +- `AccountSelectionView`:多账号选择弹窗,展示景区账号和门店账号。 +- `LoginViewModel`:维护表单状态、校验状态、登录请求状态和多账号选择状态。 +- `AuthAPI`:封装 `/api/app/v9/login` 和 `/api/app/v9/set-user`。 +- `LoginRequest`:登录请求体。 +- `SetUserRequest`:账号选择请求体。 +- `V9AuthResponse`:v9 登录和 set-user 响应。 +- `V9ScenicUser` / `V9StoreUser`:后端返回的景区账号和门店账号。 +- `AccountSwitchAccount`:统一后的可选择账号模型。 + +## 登录流程 + +1. 用户输入手机号和密码。 +2. `LoginViewModel.validateForLogin` 校验手机号、密码和协议勾选状态。 +3. 未勾选协议时,`LoginView` 弹出协议确认 Sheet。 +4. 校验通过后,`LoginViewModel.login` 调用 `AuthAPI.login`。 +5. 登录接口返回临时 token 和可用账号列表。 +6. `LoginViewModel` 过滤掉业务账号 ID 无效的账号。 +7. 没有可用账号时抛出 `LoginFlowError.noAvailableAccount`。 +8. 只有一个账号时,自动调用 `AuthAPI.setUser`,并返回 `.completed`。 +9. 多个账号时,保存 `AccountSelectionPayload`,并返回 `.needsAccountSelection`。 +10. `LoginView` 展示 `AccountSelectionView`。 +11. 用户确认账号后,`LoginViewModel.selectAccount` 使用临时 token 调用 set-user。 +12. set-user 返回正式 token 后,`LoginView` 调用 `AuthSessionCoordinator.completeLogin` 写入 token,并同步用户资料、角色权限、景区和门店。 + +## 账号模型转换 + +后端景区账号和门店账号字段不完全一致,统一转换为 `AccountSwitchAccount`: +- 景区账号通过 `ssUserId`、`scenicUserId`、`userId`、`id` 兜底生成业务账号 ID。 +- 门店账号通过 `storeUserId`、`userId`、`id` 兜底生成业务账号 ID。 +- `AccountSwitchAccount.toSetUserRequest` 根据账号类型生成 `store_user_id` 或 `ss_user_id`。 + +`V9AuthResponse` 还会派生: +- `accounts`:合并后的账号选择列表。 +- `primaryProfile`:登录后用于全局展示的账号资料。 +- `scenicScopes`:当前账号可用景区作用域。 +- `storeScopes`:当前账号可用门店作用域。 + +## 缓存规则 + +Auth 模块不直接写缓存。登录成功后的缓存由 `AuthSessionCoordinator` 负责: +- 正式 token 写 Keychain。 +- 上次手机号和协议状态写 UserDefaults。 +- 账号资料、当前角色和业务作用域写入账号快照。 + +临时 token 只存在于 `AccountSelectionPayload`,不落盘。 diff --git a/suixinkan_ios/Features/Auth/Models/AuthModels.swift b/suixinkan_ios/Features/Auth/Models/AuthModels.swift new file mode 100644 index 0000000..a02b708 --- /dev/null +++ b/suixinkan_ios/Features/Auth/Models/AuthModels.swift @@ -0,0 +1,409 @@ +// +// AuthModels.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 登录请求实体,表示手机号密码登录接口需要的参数。 +struct LoginRequest: Encodable { + let username: String + let type: Int + let password: String + let mobile: String + let code: String + + /// 创建手机号密码登录请求,并填充后端要求的默认字段。 + init(username: String, password: String) { + self.username = username + self.type = 1 + self.password = password + self.mobile = "" + self.code = "" + } +} + +/// 账号选择请求实体,表示 set-user 接口需要的景区账号或门店账号 ID。 +struct SetUserRequest: Encodable, Equatable { + let storeUserId: Int? + let ssUserId: Int? + + /// set-user 请求的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case storeUserId = "store_user_id" + case ssUserId = "ss_user_id" + } + + /// 创建账号选择请求,门店账号传 storeUserId,景区账号传 ssUserId。 + init(storeUserId: Int? = nil, ssUserId: Int? = nil) { + self.storeUserId = storeUserId + self.ssUserId = ssUserId + } +} + +/// 可切换账号实体,统一表示登录返回的景区账号和门店账号。 +struct AccountSwitchAccount: Identifiable, Hashable { + let accountType: String + let businessUserId: Int + let title: String + let subtitle: String + let phone: String + let avatar: String + let scenicName: String + let storeId: Int? + let storeName: String + let scenicId: Int? + let isCurrent: Bool + + var id: String { + "\(accountType)_\(businessUserId)" + } + + var isStoreUser: Bool { + accountType == V9StoreUser.accountTypeValue + } + + var accountTypeLabel: String { + isStoreUser ? "门店账号" : "景区账号" + } + + /// 转换为 set-user 接口请求参数。 + func toSetUserRequest() -> SetUserRequest { + if isStoreUser { + return SetUserRequest(storeUserId: businessUserId) + } + return SetUserRequest(ssUserId: businessUserId) + } +} + +/// 登录账号选择载荷,保存临时 token 和待用户选择的账号列表。 +struct AccountSelectionPayload: Equatable, Identifiable { + let id = UUID() + let tempToken: String + let accounts: [AccountSwitchAccount] + + var hasTempToken: Bool { + !tempToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +/// v9 登录响应实体,包含 token、景区账号列表和门店账号列表。 +struct V9AuthResponse: Decodable, Equatable { + let token: String + let scenicUsers: [V9ScenicUser] + let storeUsers: [V9StoreUser] + + /// 合并景区账号和门店账号,供账号选择页展示。 + var accounts: [AccountSwitchAccount] { + scenicUsers.map { $0.toAccountSwitchAccount() } + storeUsers.map { $0.toAccountSwitchAccount() } + } + + /// 提取当前或首个账号作为全局账号资料。 + var primaryProfile: AccountProfile? { + if let storeUser = storeUsers.first(where: \.isCurrent) ?? storeUsers.first { + return AccountProfile( + userId: String(storeUser.userId), + displayName: storeUser.displayName, + phone: storeUser.phone.nonEmpty, + avatarURL: storeUser.avatar.nonEmpty + ) + } + + if let scenicUser = scenicUsers.first(where: \.isCurrent) ?? scenicUsers.first { + return AccountProfile( + userId: String(scenicUser.userId), + displayName: scenicUser.displayName, + phone: scenicUser.phone.nonEmpty, + avatarURL: nil + ) + } + + return nil + } + + /// 提取登录响应中的景区作用域,并按当前账号优先排序。 + var scenicScopes: [BusinessScope] { + var seen = Set() + let scenicFromScenicUsers = scenicUsers + .sorted { $0.isCurrent && !$1.isCurrent } + .compactMap { user -> BusinessScope? in + guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil } + return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic) + } + let scenicFromStoreUsers = storeUsers + .sorted { $0.isCurrent && !$1.isCurrent } + .compactMap { user -> BusinessScope? in + guard user.scenicId > 0, seen.insert(user.scenicId).inserted else { return nil } + return BusinessScope(id: user.scenicId, name: user.scenicName, kind: .scenic) + } + return scenicFromScenicUsers + scenicFromStoreUsers + } + + /// 提取登录响应中的门店作用域,并按当前账号优先排序。 + var storeScopes: [BusinessScope] { + var seen = Set() + return storeUsers + .sorted { $0.isCurrent && !$1.isCurrent } + .compactMap { user -> BusinessScope? in + guard user.storeId > 0, seen.insert(user.storeId).inserted else { return nil } + return BusinessScope( + id: user.storeId, + name: user.storeName, + kind: .store, + parentScenicId: user.scenicId > 0 ? user.scenicId : nil + ) + } + } + + /// v9 登录响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case token + case scenicUsers = "scenic_users" + case storeUsers = "store_users" + } + + /// 创建空响应或测试响应。 + init(token: String = "", scenicUsers: [V9ScenicUser] = [], storeUsers: [V9StoreUser] = []) { + self.token = token + self.scenicUsers = scenicUsers + self.storeUsers = storeUsers + } + + /// 自定义解码,兼容后端账号数组缺失的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + token = try container.decodeLossyString(forKey: .token) + scenicUsers = (try? container.decodeIfPresent([V9ScenicUser].self, forKey: .scenicUsers)) ?? [] + storeUsers = (try? container.decodeIfPresent([V9StoreUser].self, forKey: .storeUsers)) ?? [] + } +} + +/// v9 景区账号实体,表示用户可进入的某个景区身份。 +struct V9ScenicUser: Decodable, Equatable { + static let accountTypeValue = "scenic_user" + + let accountType: String + let id: Int + let userId: Int + let scenicUserId: Int + let ssUserId: Int + let username: String + let realName: String + let nickname: String + let phone: String + let scenicId: Int + let scenicName: String + let isCurrent: Bool + + /// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。 + var businessUserId: Int { + [ssUserId, scenicUserId, userId, id].first { $0 > 0 } ?? 0 + } + + /// 用于界面展示的景区账号名称。 + var displayName: String { + scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username + } + + /// 转换为账号选择页使用的统一账号实体。 + func toAccountSwitchAccount() -> AccountSwitchAccount { + AccountSwitchAccount( + accountType: accountType.nonEmpty ?? Self.accountTypeValue, + businessUserId: businessUserId, + title: scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? "景区账号", + subtitle: [realName.nonEmpty, nickname.nonEmpty] + .compactMap(\.self) + .joined(separator: " · "), + phone: phone, + avatar: "", + scenicName: scenicName, + storeId: nil, + storeName: "", + scenicId: scenicId > 0 ? scenicId : nil, + isCurrent: isCurrent + ) + } + + /// 景区账号响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case accountType = "account_type" + case id + case userId = "user_id" + case scenicUserId = "scenic_user_id" + case ssUserId = "ss_user_id" + case username + case realName = "real_name" + case nickname + case phone + case scenicId = "scenic_id" + case scenicName = "scenic_name" + case isCurrent = "is_current" + } + + /// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue + id = try container.decodeLossyInt(forKey: .id) ?? 0 + userId = try container.decodeLossyInt(forKey: .userId) ?? 0 + scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0 + ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0 + username = try container.decodeLossyString(forKey: .username) + realName = try container.decodeLossyString(forKey: .realName) + nickname = try container.decodeLossyString(forKey: .nickname) + phone = try container.decodeLossyString(forKey: .phone) + scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 + scenicName = try container.decodeLossyString(forKey: .scenicName) + isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false + } +} + +/// v9 门店账号实体,表示用户可进入的某个门店身份。 +struct V9StoreUser: Decodable, Equatable { + static let accountTypeValue = "store_user" + + let accountType: String + let id: Int + let userId: Int + let storeUserId: Int + let username: String + let userName: String + let realName: String + let phone: String + let avatar: String + let scenicId: Int + let scenicName: String + let storeId: Int + let storeName: String + let isCurrent: Bool + + /// set-user 使用的业务账号 ID,按后端可用字段优先级兜底。 + var businessUserId: Int { + [storeUserId, userId, id].first { $0 > 0 } ?? 0 + } + + /// 用于界面展示的门店账号名称。 + var displayName: String { + storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username + } + + /// 转换为账号选择页使用的统一账号实体。 + func toAccountSwitchAccount() -> AccountSwitchAccount { + AccountSwitchAccount( + accountType: accountType.nonEmpty ?? Self.accountTypeValue, + businessUserId: businessUserId, + title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号", + subtitle: [scenicName.nonEmpty, realName.nonEmpty ?? userName.nonEmpty] + .compactMap(\.self) + .joined(separator: " · "), + phone: phone, + avatar: avatar, + scenicName: scenicName, + storeId: storeId > 0 ? storeId : nil, + storeName: storeName, + scenicId: scenicId > 0 ? scenicId : nil, + isCurrent: isCurrent + ) + } + + /// 门店账号响应的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case accountType = "account_type" + case id + case userId = "user_id" + case storeUserId = "store_user_id" + case username + case userName = "user_name" + case realName = "real_name" + case phone + case avatar + case scenicId = "scenic_id" + case scenicName = "scenic_name" + case storeId = "store_id" + case storeName = "store_name" + case isCurrent = "is_current" + } + + /// 自定义解码,兼容 ID 字段类型不稳定和字段缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + accountType = try container.decodeLossyString(forKey: .accountType).nonEmpty ?? Self.accountTypeValue + id = try container.decodeLossyInt(forKey: .id) ?? 0 + userId = try container.decodeLossyInt(forKey: .userId) ?? 0 + storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0 + username = try container.decodeLossyString(forKey: .username) + userName = try container.decodeLossyString(forKey: .userName) + realName = try container.decodeLossyString(forKey: .realName) + phone = try container.decodeLossyString(forKey: .phone) + avatar = try container.decodeLossyString(forKey: .avatar) + scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 + scenicName = try container.decodeLossyString(forKey: .scenicName) + storeId = try container.decodeLossyInt(forKey: .storeId) ?? 0 + storeName = try container.decodeLossyString(forKey: .storeName) + isCurrent = try container.decodeLossyBool(forKey: .isCurrent) ?? false + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } + + /// 将 Bool、数字或布尔字符串宽松解码为布尔值。 + func decodeLossyBool(forKey key: Key) throws -> Bool? { + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value != 0 + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["1", "true", "yes"].contains(normalized) { return true } + if ["0", "false", "no"].contains(normalized) { return false } + } + return nil + } +} + +private extension String { + var nonEmpty: String? { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } +} diff --git a/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift b/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift new file mode 100644 index 0000000..9148992 --- /dev/null +++ b/suixinkan_ios/Features/Auth/ViewControllers/AccountSelectionViewController.swift @@ -0,0 +1,291 @@ +// +// AccountSelectionViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +@MainActor +/// 多账号登录时的账号选择页,展示景区/门店账号列表并提交选择。 +final class AccountSelectionViewController: UIViewController { + + private let payload: AccountSelectionPayload + private var isLoading: Bool + private let onCancel: () -> Void + private let onConfirm: (AccountSwitchAccount) -> Void + + private var selectedAccountId: String? + private let tableView = UITableView(frame: .zero, style: .plain) + private let confirmButton = UIButton(type: .system) + private let bottomBar = UIView() + + init( + payload: AccountSelectionPayload, + isLoading: Bool, + onCancel: @escaping () -> Void, + onConfirm: @escaping (AccountSwitchAccount) -> Void + ) { + self.payload = payload + self.isLoading = isLoading + self.onCancel = onCancel + self.onConfirm = onConfirm + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "选择账号" + view.backgroundColor = UIColor(hex: 0xF5F7FB) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "取消", style: .plain, target: self, action: #selector(cancelTapped)) + isModalInPresentation = isLoading + + selectedAccountId = payload.accounts.first?.id + configureTableView() + configureBottomBar() + } + + func updateLoading(_ loading: Bool) { + isLoading = loading + isModalInPresentation = loading + confirmButton.isEnabled = canConfirm + confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6) + } + + private var selectedAccount: AccountSwitchAccount? { + payload.accounts.first { $0.id == selectedAccountId } + } + + private var canConfirm: Bool { + selectedAccount != nil && !isLoading + } + + private func configureTableView() { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.dataSource = self + tableView.delegate = self + tableView.register(AccountSelectionCell.self, forCellReuseIdentifier: AccountSelectionCell.reuseIdentifier) + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(90) + } + } + + private func configureBottomBar() { + bottomBar.backgroundColor = .white + + let divider = UIView() + divider.backgroundColor = UIColor.separator + + confirmButton.setTitle("进入系统", for: .normal) + confirmButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold) + confirmButton.setTitleColor(.white, for: .normal) + confirmButton.layer.cornerRadius = AppMetrics.CornerRadius.button + confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + updateLoading(isLoading) + + view.addSubview(bottomBar) + bottomBar.addSubview(divider) + bottomBar.addSubview(confirmButton) + + bottomBar.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + + divider.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(0.5) + } + + confirmButton.snp.makeConstraints { make in + make.top.equalTo(divider.snp.bottom).offset(AppMetrics.Spacing.medium) + make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.medium) + make.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight) + } + } + + @objc private func cancelTapped() { + onCancel() + dismiss(animated: true) + } + + @objc private func confirmTapped() { + guard let selectedAccount else { return } + onConfirm(selectedAccount) + } +} + +extension AccountSelectionViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + payload.accounts.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: AccountSelectionCell.reuseIdentifier, + for: indexPath + ) as? AccountSelectionCell else { + return UITableViewCell() + } + let account = payload.accounts[indexPath.row] + cell.configure(account: account, selected: account.id == selectedAccountId) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + selectedAccountId = payload.accounts[indexPath.row].id + tableView.reloadData() + confirmButton.isEnabled = canConfirm + confirmButton.backgroundColor = canConfirm ? AppDesign.primary : UIColor(hex: 0xC9CED6) + } +} + +/// 账号选择列表 Cell。 +private final class AccountSelectionCell: UITableViewCell { + static let reuseIdentifier = "AccountSelectionCell" + + private let avatarView = UIView() + private let avatarLabel = UILabel() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let phoneLabel = UILabel() + private let typeTag = UILabel() + private let currentTag = UILabel() + private let checkmark = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .white + contentView.layer.cornerRadius = AppMetrics.CornerRadius.button + contentView.layer.masksToBounds = true + + avatarView.layer.cornerRadius = AppMetrics.ControlSize.primaryButtonHeight / 2 + avatarLabel.font = .systemFont(ofSize: AppMetrics.FontSize.callout, weight: .semibold) + + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold) + titleLabel.textColor = AppDesign.textPrimary + + subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.footnote) + subtitleLabel.textColor = AppDesign.textSecondary + + phoneLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + phoneLabel.textColor = UIColor(hex: 0x9AA1AA) + + typeTag.font = .systemFont(ofSize: 11, weight: .semibold) + typeTag.textAlignment = .center + typeTag.layer.cornerRadius = AppMetrics.Spacing.xxSmall + typeTag.clipsToBounds = true + + currentTag.font = .systemFont(ofSize: 11, weight: .semibold) + currentTag.textAlignment = .center + currentTag.text = "当前" + currentTag.textColor = AppDesign.primary + currentTag.backgroundColor = AppDesign.primarySoft + currentTag.layer.cornerRadius = AppMetrics.Spacing.xxSmall + currentTag.clipsToBounds = true + currentTag.isHidden = true + + checkmark.contentMode = .scaleAspectFit + + contentView.addSubview(avatarView) + avatarView.addSubview(avatarLabel) + contentView.addSubview(titleLabel) + contentView.addSubview(subtitleLabel) + contentView.addSubview(phoneLabel) + contentView.addSubview(typeTag) + contentView.addSubview(currentTag) + contentView.addSubview(checkmark) + + avatarView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(AppMetrics.FontSize.subheadline) + make.centerY.equalToSuperview() + make.width.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight) + } + + avatarLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + } + + titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().inset(AppMetrics.FontSize.subheadline) + make.leading.equalTo(avatarView.snp.trailing).offset(AppMetrics.FontSize.footnote) + make.trailing.lessThanOrEqualTo(checkmark.snp.leading).offset(-8) + } + + currentTag.snp.makeConstraints { make in + make.leading.equalTo(titleLabel.snp.trailing).offset(AppMetrics.Spacing.xSmall) + make.centerY.equalTo(titleLabel) + make.height.equalTo(AppMetrics.Spacing.sheet) + } + + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.xxSmall) + make.leading.equalTo(titleLabel) + make.trailing.equalTo(checkmark.snp.leading).offset(-8) + } + + phoneLabel.snp.makeConstraints { make in + make.top.equalTo(subtitleLabel.snp.bottom).offset(AppMetrics.Spacing.xxSmall) + make.leading.equalTo(titleLabel) + make.bottom.equalToSuperview().inset(AppMetrics.FontSize.subheadline) + } + + typeTag.snp.makeConstraints { make in + make.trailing.equalTo(checkmark.snp.leading).offset(-AppMetrics.Spacing.xSmall) + make.top.equalTo(titleLabel) + make.height.equalTo(AppMetrics.Spacing.sheet) + } + + checkmark.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppMetrics.FontSize.subheadline) + make.centerY.equalToSuperview() + make.width.height.equalTo(AppMetrics.ControlSize.passwordIcon) + } + } + + required init?(coder: NSCoder) { + nil + } + + func configure(account: AccountSwitchAccount, selected: Bool) { + titleLabel.text = account.title.isEmpty ? account.accountTypeLabel : account.title + subtitleLabel.text = account.subtitle + subtitleLabel.isHidden = account.subtitle.isEmpty + phoneLabel.text = account.phone + phoneLabel.isHidden = account.phone.isEmpty + currentTag.isHidden = !account.isCurrent + + if account.isStoreUser { + avatarView.backgroundColor = UIColor(hex: 0xE8F8F1) + avatarLabel.text = "店" + avatarLabel.textColor = UIColor(hex: 0x0F9F6E) + typeTag.text = " 门店 " + typeTag.textColor = UIColor(hex: 0x0F9F6E) + typeTag.backgroundColor = UIColor(hex: 0xE8F8F1) + } else { + avatarView.backgroundColor = UIColor(hex: 0xF3ECFF) + avatarLabel.text = "景" + avatarLabel.textColor = UIColor(hex: 0x7C3AED) + typeTag.text = " 景区 " + typeTag.textColor = UIColor(hex: 0x7C3AED) + typeTag.backgroundColor = UIColor(hex: 0xF3ECFF) + } + + let symbolName = selected ? "checkmark.circle.fill" : "circle" + checkmark.image = UIImage(systemName: symbolName) + checkmark.tintColor = selected ? AppDesign.primary : UIColor(hex: 0xB6BECA) + + contentView.layer.borderWidth = selected ? 1.4 : 1 + contentView.layer.borderColor = (selected ? AppDesign.primary : UIColor(hex: 0xE6ECF4)).cgColor + } +} diff --git a/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift b/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift new file mode 100644 index 0000000..954170d --- /dev/null +++ b/suixinkan_ios/Features/Auth/ViewControllers/LoginViewController.swift @@ -0,0 +1,532 @@ +// +// LoginViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +@MainActor +/// 登录页,展示手机号/密码表单、协议勾选和多账号选择入口。 +final class LoginViewController: UIViewController { + + private let services: AppServices + private let viewModel = LoginViewModel() + + private let scrollView = UIScrollView() + private let contentView = UIView() + private let backgroundImageView = UIImageView(image: UIImage(named: "LoginBackground")) + private let titleLabel = UILabel() + private let cardView = UIView() + private let usernameField = LoginInputField(iconName: "person", placeholder: "请输入手机号", isSecure: false) + private let passwordField = LoginInputField(iconName: "lock", placeholder: "请输入密码", isSecure: true) + private let privacyButton = UIButton(type: .custom) + private let privacyLabel = UILabel() + private let userAgreementButton = UIButton(type: .system) + private let privacyPolicyButton = UIButton(type: .system) + private let loginButton = UIButton(type: .system) + + init(services: AppServices) { + self.services = services + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(hex: 0x0B1220) + configureViews() + bindViewModel() + viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences()) + } + + private func configureViews() { + backgroundImageView.contentMode = .scaleAspectFill + backgroundImageView.clipsToBounds = true + + titleLabel.text = "欢迎使用\n随心瞰商家版" + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.largeTitle, weight: .semibold) + titleLabel.textColor = .white + titleLabel.numberOfLines = 0 + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppMetrics.CornerRadius.card + cardView.layer.shadowColor = UIColor.black.cgColor + cardView.layer.shadowOpacity = 0.12 + cardView.layer.shadowRadius = 18 + cardView.layer.shadowOffset = CGSize(width: 0, height: 8) + + usernameField.textField.keyboardType = .phonePad + usernameField.textField.textContentType = .telephoneNumber + usernameField.textField.autocorrectionType = .no + usernameField.textField.autocapitalizationType = .none + usernameField.textField.returnKeyType = .next + usernameField.textField.accessibilityIdentifier = "login.username" + usernameField.textField.delegate = self + usernameField.textField.addTarget(self, action: #selector(usernameChanged), for: .editingChanged) + + passwordField.textField.autocorrectionType = .no + passwordField.textField.autocapitalizationType = .none + passwordField.textField.returnKeyType = .go + passwordField.textField.accessibilityIdentifier = "login.password" + passwordField.textField.delegate = self + passwordField.textField.addTarget(self, action: #selector(passwordChanged), for: .editingChanged) + passwordField.onToggleVisibility = { [weak self] in + self?.viewModel.showsPassword.toggle() + } + + privacyButton.accessibilityIdentifier = "login.privacy" + privacyButton.addTarget(self, action: #selector(togglePrivacy), for: .touchUpInside) + + configureAgreementText() + + loginButton.setTitle("登录", for: .normal) + loginButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) + loginButton.layer.cornerRadius = AppMetrics.CornerRadius.button + loginButton.accessibilityIdentifier = "login.submit" + loginButton.addTarget(self, action: #selector(loginTapped), for: .touchUpInside) + + view.addSubview(backgroundImageView) + view.addSubview(scrollView) + scrollView.addSubview(contentView) + contentView.addSubview(titleLabel) + contentView.addSubview(cardView) + + cardView.addSubview(usernameField) + cardView.addSubview(passwordField) + cardView.addSubview(privacyButton) + cardView.addSubview(privacyLabel) + cardView.addSubview(loginButton) + + backgroundImageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + scrollView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + contentView.snp.makeConstraints { make in + make.edges.equalTo(scrollView.contentLayoutGuide) + make.width.equalTo(scrollView.frameLayoutGuide) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(contentView.safeAreaLayoutGuide).offset(72) + make.leading.trailing.equalToSuperview().inset(24) + } + titleLabel.accessibilityIdentifier = "login.title" + + cardView.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.xxLarge) + make.leading.trailing.equalToSuperview().inset(20) + make.bottom.equalToSuperview().inset(AppMetrics.Spacing.xxLarge) + } + + usernameField.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium) + make.height.equalTo(AppMetrics.ControlSize.inputHeight) + } + + passwordField.snp.makeConstraints { make in + make.top.equalTo(usernameField.snp.bottom).offset(AppMetrics.Spacing.xSmall) + make.leading.trailing.equalTo(usernameField) + make.height.equalTo(AppMetrics.ControlSize.inputHeight) + } + + privacyButton.snp.makeConstraints { make in + make.top.equalTo(passwordField.snp.bottom).offset(AppMetrics.ControlSize.checkboxTapArea) + make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium) + make.width.height.equalTo(AppMetrics.ControlSize.checkboxTapArea) + } + + privacyLabel.snp.makeConstraints { make in + make.leading.equalTo(privacyButton.snp.trailing) + make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium) + make.top.equalTo(privacyButton).offset(AppMetrics.Spacing.xxSmall) + } + + loginButton.snp.makeConstraints { make in + make.top.equalTo(privacyLabel.snp.bottom).offset(AppMetrics.Spacing.mediumLarge) + make.leading.trailing.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium) + make.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight) + } + + let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + tap.cancelsTouchesInView = false + view.addGestureRecognizer(tap) + } + + private func configureAgreementText() { + privacyLabel.numberOfLines = 0 + privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + + userAgreementButton.setTitle("《用户协议》", for: .normal) + userAgreementButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + userAgreementButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal) + userAgreementButton.accessibilityIdentifier = "login.userAgreement" + userAgreementButton.addTarget(self, action: #selector(openUserAgreement), for: .touchUpInside) + + privacyPolicyButton.setTitle("《隐私政策》", for: .normal) + privacyPolicyButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + privacyPolicyButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal) + privacyPolicyButton.accessibilityIdentifier = "login.privacyPolicy" + privacyPolicyButton.addTarget(self, action: #selector(openPrivacyPolicy), for: .touchUpInside) + + let prefix = UILabel() + prefix.text = "已阅读并同意" + prefix.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + prefix.textColor = UIColor(hex: 0x666666) + + let separator = UILabel() + separator.text = "与" + separator.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + separator.textColor = UIColor(hex: 0x666666) + + let row = UIStackView(arrangedSubviews: [prefix, userAgreementButton, separator, privacyPolicyButton]) + row.axis = .horizontal + row.spacing = 0 + row.alignment = .center + privacyLabel.addSubview(row) + row.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + private func bindViewModel() { + viewModel.onChange = { [weak self] in + self?.renderViewModel() + } + renderViewModel() + } + + private func renderViewModel() { + if usernameField.textField.text != viewModel.username { + usernameField.textField.text = viewModel.username + } + if passwordField.textField.text != viewModel.password { + passwordField.textField.text = viewModel.password + } + + passwordField.setSecureEntry(!viewModel.showsPassword) + + let checkboxImage = viewModel.privacyChecked ? "LoginCheckboxChecked" : "LoginCheckboxUnchecked" + privacyButton.setImage(UIImage(named: checkboxImage), for: .normal) + + loginButton.isEnabled = viewModel.canSubmit && !viewModel.isLoading + loginButton.backgroundColor = viewModel.canSubmit ? AppDesign.primary : .gray + loginButton.setTitleColor(.white, for: .normal) + loginButton.alpha = viewModel.isLoading ? 0.7 : 1 + + if viewModel.showsAgreementSheet { + viewModel.showsAgreementSheet = false + presentAgreementSheet() + } + + if let payload = viewModel.pendingAccountSelection { + if let controller = accountSelectionController { + controller.updateLoading(viewModel.isSelectingAccount) + } else if presentedViewController == nil { + presentAccountSelection(payload) + } + } else if accountSelectionController != nil { + dismiss(animated: true) + accountSelectionController = nil + } + } + + private weak var accountSelectionController: AccountSelectionViewController? + + @objc private func usernameChanged() { + viewModel.username = usernameField.textField.text ?? "" + viewModel.normalizeUsernameCountryCodeIfNeeded() + services.toastCenter.dismiss() + } + + @objc private func passwordChanged() { + viewModel.password = passwordField.textField.text ?? "" + services.toastCenter.dismiss() + } + + @objc private func togglePrivacy() { + viewModel.privacyChecked.toggle() + } + + @objc private func openUserAgreement() { + showToast("用户协议页面待接入") + } + + @objc private func openPrivacyPolicy() { + showToast("隐私政策页面待接入") + } + + @objc private func loginTapped() { + view.endEditing(true) + performLogin() + } + + @objc private func dismissKeyboard() { + view.endEditing(true) + } + + private func performLogin() { + if let validationError = viewModel.validateForLogin() { + if validationError == .privacyUnchecked { + presentAgreementSheet() + } else { + showToast(validationError.message) + if validationError.focusField == .username { + usernameField.textField.becomeFirstResponder() + } else if validationError.focusField == .password { + passwordField.textField.becomeFirstResponder() + } + } + return + } + + Task { + do { + try await services.globalLoading.withLoading(message: "登录中...") { + let resolution = try await viewModel.login(authAPI: services.authAPI) + switch resolution { + case let .completed(response): + await completeLogin(with: response) + case .needsAccountSelection: + break + } + } + } catch is CancellationError { + return + } catch { + showToast(error.localizedDescription) + } + } + } + + private func presentAgreementSheet() { + let controller = LoginAgreementConsentViewController( + onOpenAgreement: { [weak self] title in + self?.showToast("\(title)页面待接入") + }, + onAgreeAndContinue: { [weak self] in + self?.viewModel.acceptAgreement() + self?.performLogin() + } + ) + if let sheet = controller.sheetPresentationController { + sheet.detents = [.custom(resolver: { _ in 240 })] + sheet.prefersGrabberVisible = true + } + present(controller, animated: true) + } + + private func presentAccountSelection(_ payload: AccountSelectionPayload) { + let controller = AccountSelectionViewController( + payload: payload, + isLoading: viewModel.isSelectingAccount, + onCancel: { [weak self] in + self?.viewModel.clearPendingAccountSelection() + self?.accountSelectionController = nil + }, + onConfirm: { [weak self] account in + self?.selectAccount(account) + } + ) + accountSelectionController = controller + let navigation = UINavigationController(rootViewController: controller) + navigation.modalPresentationStyle = .formSheet + present(navigation, animated: true) + } + + private func selectAccount(_ account: AccountSwitchAccount) { + Task { + do { + try await services.globalLoading.withLoading(message: "账号切换中...") { + let response = try await viewModel.selectAccount(account, authAPI: services.authAPI) + await completeLogin(with: response) + accountSelectionController = nil + dismiss(animated: true) + } + } catch is CancellationError { + return + } catch { + showToast(error.localizedDescription) + } + } + } + + private func completeLogin(with response: V9AuthResponse) async { + do { + try await services.authSessionCoordinator.completeLogin( + with: response, + username: viewModel.normalizedUsername, + privacyAgreementAccepted: viewModel.privacyChecked, + appSession: services.appSession, + accountContext: services.accountContext, + permissionContext: services.permissionContext, + profileAPI: services.profileAPI, + accountContextAPI: services.accountContextAPI + ) + } catch { + showToast(error.localizedDescription) + } + } +} + +extension LoginViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField === usernameField.textField { + passwordField.textField.becomeFirstResponder() + } else if viewModel.canSubmit, !viewModel.isLoading { + performLogin() + } + return true + } +} + +/// 登录输入框,包含图标、文本框和密码可见切换。 +private final class LoginInputField: UIView { + let textField = UITextField() + var onToggleVisibility: (() -> Void)? + + private let toggleButton = UIButton(type: .custom) + private var isSecure = false + + init(iconName: String, placeholder: String, isSecure: Bool) { + self.isSecure = isSecure + super.init(frame: .zero) + backgroundColor = UIColor(hex: 0xF1F5F9) + layer.cornerRadius = AppMetrics.CornerRadius.input + layer.borderWidth = 1 + layer.borderColor = UIColor(hex: 0xDDE3EA).cgColor + + let icon = UIImageView(image: UIImage(systemName: iconName)) + icon.tintColor = AppDesign.textSecondary + icon.contentMode = .scaleAspectFit + + textField.placeholder = placeholder + textField.font = .systemFont(ofSize: AppMetrics.FontSize.body) + textField.textColor = AppDesign.textPrimary + textField.tintColor = AppDesign.primary + textField.borderStyle = .none + textField.isSecureTextEntry = isSecure + + addSubview(icon) + addSubview(textField) + + icon.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium) + make.centerY.equalToSuperview() + make.width.height.equalTo(AppMetrics.ControlSize.smallIcon) + } + + if isSecure { + toggleButton.addTarget(self, action: #selector(toggleVisibility), for: .touchUpInside) + addSubview(toggleButton) + toggleButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(AppMetrics.Spacing.small) + make.centerY.equalToSuperview() + make.width.height.equalTo(AppMetrics.ControlSize.iconTapArea) + } + textField.snp.makeConstraints { make in + make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall) + make.trailing.equalTo(toggleButton.snp.leading) + make.centerY.equalToSuperview() + } + } else { + textField.snp.makeConstraints { make in + make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall) + make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium) + make.centerY.equalToSuperview() + } + } + } + + required init?(coder: NSCoder) { + nil + } + + func setSecureEntry(_ secure: Bool) { + textField.isSecureTextEntry = secure + let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible" + toggleButton.setImage(UIImage(named: imageName), for: .normal) + } + + @objc private func toggleVisibility() { + onToggleVisibility?() + } +} + +/// 协议确认弹窗,引导用户勾选协议后继续登录。 +private final class LoginAgreementConsentViewController: UIViewController { + + private let onOpenAgreement: (String) -> Void + private let onAgreeAndContinue: () -> Void + + init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) { + self.onOpenAgreement = onOpenAgreement + self.onAgreeAndContinue = onAgreeAndContinue + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + nil + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + let titleLabel = UILabel() + titleLabel.text = "请先阅读并同意相关协议" + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold) + titleLabel.textColor = AppDesign.textPrimary + titleLabel.textAlignment = .center + + let descriptionLabel = UILabel() + descriptionLabel.text = "为了保障你的账号安全和服务体验,请阅读并同意用户协议和隐私政策。" + descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + descriptionLabel.textColor = AppDesign.textSecondary + descriptionLabel.numberOfLines = 0 + descriptionLabel.textAlignment = .center + + let continueButton = UIButton(type: .system) + continueButton.setTitle("同意并继续", for: .normal) + continueButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) + continueButton.backgroundColor = AppDesign.primary + continueButton.setTitleColor(.white, for: .normal) + continueButton.layer.cornerRadius = AppMetrics.CornerRadius.button + continueButton.accessibilityIdentifier = "login.agreement.continue" + continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside) + + view.addSubview(titleLabel) + view.addSubview(descriptionLabel) + view.addSubview(continueButton) + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.sheet) + make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large) + } + + descriptionLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.small) + make.leading.trailing.equalTo(titleLabel) + } + + continueButton.snp.makeConstraints { make in + make.leading.trailing.equalTo(titleLabel) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.mediumLarge) + make.height.equalTo(AppMetrics.ControlSize.sheetButtonHeight) + } + } + + @objc private func continueTapped() { + dismiss(animated: true) { [onAgreeAndContinue] in + onAgreeAndContinue() + } + } +} diff --git a/suixinkan_ios/Features/Auth/ViewModels/LoginViewModel.swift b/suixinkan_ios/Features/Auth/ViewModels/LoginViewModel.swift new file mode 100644 index 0000000..546a610 --- /dev/null +++ b/suixinkan_ios/Features/Auth/ViewModels/LoginViewModel.swift @@ -0,0 +1,212 @@ +// +// LoginViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/18. +// + +import Foundation + +/// 登录输入焦点实体,标识当前需要聚焦的输入框。 +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 +/// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。 +final class LoginViewModel { + var onChange: (() -> Void)? + var username = "" { didSet { onChange?() } } + var password = "" { didSet { onChange?() } } + var privacyChecked = false { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isSelectingAccount = false { didSet { onChange?() } } + var showsPassword = false { didSet { onChange?() } } + var showsAgreementSheet = false { didSet { onChange?() } } + var pendingAccountSelection: AccountSelectionPayload? { didSet { onChange?() } } + + var canSubmit: Bool { + isValidPhone && !trimmedPassword.isEmpty + } + + var trimmedPassword: String { + password.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var normalizedUsername: String { + normalizedPhoneNumber(username) + } + + var isValidPhone: Bool { + normalizedUsername.count == 11 && normalizedUsername.first == "1" + } + + /// 应用本地登录偏好,只恢复手机号和协议状态,不恢复密码。 + func applyPreferences(_ preferences: LoginPreferences) { + if username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let lastUsername = preferences.lastUsername { + username = lastUsername + } + privacyChecked = preferences.privacyAgreementAccepted + } + + /// 校验登录表单,并返回第一个需要处理的错误。 + func validateForLogin() -> LoginValidationError? { + guard isValidPhone else { + return .invalidPhone + } + guard !trimmedPassword.isEmpty else { + return .emptyPassword + } + guard privacyChecked else { + return .privacyUnchecked + } + return nil + } + + /// 用户同意协议后关闭协议确认弹窗。 + func acceptAgreement() { + privacyChecked = true + showsAgreementSheet = false + } + + /// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。 + func normalizeUsernameCountryCodeIfNeeded() { + let digits = username.filter(\.isNumber) + let normalizedPhone = normalizedPhoneNumber(username) + guard normalizedPhone != digits, normalizedPhone.count == 11 else { return } + username = normalizedPhone + } + + /// 发起登录,并根据账号数量决定直接完成或进入账号选择。 + func login(authAPI: AuthAPI) async throws -> LoginResolution { + guard !isLoading else { + throw CancellationError() + } + + isLoading = true + defer { isLoading = false } + + let response = try await authAPI.login( + username: normalizedUsername, + password: trimmedPassword + ) + return try await resolveLoginResponse(response, authAPI: authAPI) + } + + /// 选择一个账号并调用 set-user 换取正式 token。 + func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse { + guard let payload = pendingAccountSelection, payload.hasTempToken else { + throw LoginFlowError.missingToken + } + guard account.businessUserId > 0 else { + throw LoginFlowError.invalidAccount + } + guard !isSelectingAccount else { + throw CancellationError() + } + + isSelectingAccount = true + defer { isSelectingAccount = false } + + let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken) + guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw LoginFlowError.missingToken + } + pendingAccountSelection = nil + return response + } + + /// 清空待选账号信息,通常在用户取消账号选择时调用。 + func clearPendingAccountSelection() { + pendingAccountSelection = nil + } + + /// 解析登录响应,单账号自动 set-user,多账号保留选择载荷。 + private func resolveLoginResponse(_ response: V9AuthResponse, authAPI: AuthAPI) async throws -> LoginResolution { + let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { + throw LoginFlowError.missingToken + } + + let accounts = response.accounts.filter { $0.businessUserId > 0 } + guard !accounts.isEmpty else { + throw LoginFlowError.noAvailableAccount + } + + if accounts.count == 1, let account = accounts.first { + let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token) + guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw LoginFlowError.missingToken + } + return .completed(finalResponse) + } + + let payload = AccountSelectionPayload(tempToken: token, accounts: accounts) + pendingAccountSelection = payload + return .needsAccountSelection(payload) + } + + /// 过滤手机号中的非数字字符,并去掉 86 国家码前缀。 + private func normalizedPhoneNumber(_ value: String) -> String { + var digits = value.filter(\.isNumber) + if digits.hasPrefix("86"), digits.count == 13 { + digits.removeFirst(2) + } + return digits + } +} diff --git a/suixinkan_ios/Features/Home/Home.md b/suixinkan_ios/Features/Home/Home.md new file mode 100644 index 0000000..25086c1 --- /dev/null +++ b/suixinkan_ios/Features/Home/Home.md @@ -0,0 +1,38 @@ +# 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` 进行页面跳转。 + +## 后续迁移 + +目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。 + +后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 diff --git a/suixinkan_ios/Features/Home/HomeIconCatalog.swift b/suixinkan_ios/Features/Home/HomeIconCatalog.swift new file mode 100644 index 0000000..d07bbff --- /dev/null +++ b/suixinkan_ios/Features/Home/HomeIconCatalog.swift @@ -0,0 +1,85 @@ +// +// HomeIconCatalog.swift +// suixinkan +// + +import Foundation + +/// 首页图标目录,负责为权限 URI 提供本地图标兜底。 +enum HomeIconCatalog { + /// 返回指定 URI 对应的 SF Symbols 名称。 + static func iconName(for uri: String) -> String { + switch uri { + case "space_settings": + "person.crop.square.fill" + case "album_list": + "photo.stack" + case "album_trailer": + "square.stack.3d.up.fill" + case "wallet": + "creditcard.fill" + case "cloud_management": + "icloud.fill" + case "cloud_storage_transit": + "arrow.left.arrow.right.circle.fill" + case "asset_management", "/scenic-order-manage": + "photo.on.rectangle.angled" + case "material_upload": + "square.and.arrow.up" + case "task_management", "task_management_editor": + "checklist" + case "schedule_management": + "calendar" + case "system_settings": + "gearshape.fill" + case "message_center": + "bell.fill" + case "checkin_points": + "mappin.and.ellipse" + case "sample_management": + "point.3.connected.trianglepath.dotted" + case "sample_upload": + "square.and.arrow.up.on.square" + case "live_stream_management": + "dot.radiowaves.left.and.right" + case "verification_order": + "checkmark.seal.fill" + case "live_album": + "play.rectangle.on.rectangle.fill" + case "pm", "pm_manager", "project_edit": + "circle.grid.2x2.fill" + case "location_report", "location_report_history": + "mappin.circle.fill" + case "registration_invitation", "photographer_invite": + "envelope.open.fill" + case "store": + "storefront.fill" + case "fly", "pilot_cert", "pilot_controller": + "paperplane.fill" + case "/scenic-queue", "queue_management": + "person.3.fill" + case "operating-area": + "map.fill" + case "scenicselection": + "location.magnifyingglass" + case "scenicapplication": + "doc.badge.plus" + case "permission_apply", "permission_apply_status": + "person.badge.key" + case "scenic_settlement", "scenic_settlement_review": + "checklist" + case "payment_collection", "payment_qr", "payment_code": + "qrcode" + case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info": + "doc.text.magnifyingglass" + case "withdrawal_audit": + "banknote.fill" + case "invite_record": + "list.bullet.rectangle.fill" + case "more_functions": + "ellipsis" + default: + "square.grid.2x2.fill" + } + } +} diff --git a/suixinkan_ios/Features/Home/Models/HomeMenuItem.swift b/suixinkan_ios/Features/Home/Models/HomeMenuItem.swift new file mode 100644 index 0000000..ed8fcb9 --- /dev/null +++ b/suixinkan_ios/Features/Home/Models/HomeMenuItem.swift @@ -0,0 +1,104 @@ +// +// 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 permissionApply + case permissionApplyStatus + case scenicApplication + case moreFunctions + case settings + case paymentCollection + case wallet + case taskManagement + case taskCreate + case taskDetail(id: Int, summary: PhotographerTaskItem?) + case projectManagement + case pmProjectManagement + case projectDetail(id: Int, storeMode: Bool) + case projectEditor(id: Int?, storeMode: Bool) + case scheduleManagement + case scheduleAdd + case photographerInvite + case inviteRecord + case cloudStorage + case cloudStorageTransit + case materialLibrary + case materialUpload + case sampleLibrary + case sampleUpload + case albumList + case albumTrailer + case punchPointList + case punchPointDetail(id: Int, summary: PunchPointItem?) + case punchPointEditor(id: Int?) + case punchPointQR(id: Int, title: String, qrURL: String) + case locationReport + case locationReportHistory + case depositOrders + case withdrawalAudit + case scenicSettlement + case scenicSettlementReview + case messageCenter + case queueManagement + case liveManagement + case liveAlbum + case operatingArea + case pilotCertification + case modulePlaceholder(uri: String, title: String) +} + +/// 首页权限 URI 解析结果,区分 Tab 跳转、本地页面、已知不支持和未知占位。 +enum HomeMenuResolvedRoute: Equatable { + case tab(AppTab) + case orders(OrdersEntry) + case destination(HomeRoute) + case unsupported(uri: String, title: String, reason: String) + case placeholder(uri: String, title: String) +} + +/// 未知首页路由记录实体,用于诊断后端新增但 iOS 尚未映射的 URI。 +struct UnknownHomeRouteRecord: Codable, Equatable { + let uri: String + let title: String + let firstSeenAt: Date + let lastSeenAt: Date + let count: Int +} + +/// 首页权限路由审计项,表示一个权限菜单和它的解析结果。 +struct HomePermissionRouteAuditEntry: Equatable { + let uri: String + let title: String + let route: HomeMenuResolvedRoute +} + +/// 首页权限路由审计结果,按可路由、已知不支持、未知三类聚合。 +struct HomePermissionRouteAudit: Equatable { + let routable: [HomePermissionRouteAuditEntry] + let unsupported: [HomePermissionRouteAuditEntry] + let unknown: [HomePermissionRouteAuditEntry] + + var hasUnknownRoutes: Bool { + !unknown.isEmpty + } +} diff --git a/suixinkan_ios/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan_ios/Features/Home/Routing/HomeMenuRouter.swift new file mode 100644 index 0000000..1b4f9e6 --- /dev/null +++ b/suixinkan_ios/Features/Home/Routing/HomeMenuRouter.swift @@ -0,0 +1,503 @@ +// +// 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 .orders(.storeOrders) + case "verification_order": + return .orders(.verificationOrders) + case "photographer_stats": + return .tab(.statistics) + case "space_settings", "basic_info": + return .destination(.profileSpace) + case "system_settings": + return .destination(.settings) + case "scenicselection": + return .destination(.scenicSelection) + case "permission_apply": + return .destination(.permissionApply) + case "permission_apply_status": + return .destination(.permissionApplyStatus) + case "scenicapplication": + return .destination(.scenicApplication) + case "wallet": + return .destination(.wallet) + case "payment_collection", "payment_qr", "payment_code": + return .destination(.paymentCollection) + case "task_management", "task_management_editor": + return .destination(.taskManagement) + case "task_create": + return .destination(.taskCreate) + case "schedule_management": + return .destination(.scheduleManagement) + case "pm", "project_edit": + return .destination(.projectManagement) + case "pm_manager": + return .destination(.pmProjectManagement) + case "registration_invitation", "photographer_invite": + return .destination(.photographerInvite) + case "invite_record": + return .destination(.inviteRecord) + case "cloud_management": + return .destination(.cloudStorage) + case "cloud_storage_transit": + return .destination(.cloudStorageTransit) + case "asset_management": + return .destination(.materialLibrary) + case "material_upload": + return .destination(.materialUpload) + case "sample_management": + return .destination(.sampleLibrary) + case "sample_upload": + return .destination(.sampleUpload) + case "album_list": + return .destination(.albumList) + case "album_trailer": + return .destination(.albumTrailer) + case "checkin_points": + return .destination(.punchPointList) + case "location_report": + return .destination(.locationReport) + case "location_report_history": + return .destination(.locationReportHistory) + case "deposit_order_detail", "deposit_order", "deposit_order_shooting_info": + return .destination(.depositOrders) + case "withdrawal_audit": + return .destination(.withdrawalAudit) + case "scenic_settlement": + return .destination(.scenicSettlement) + case "scenic_settlement_review": + return .destination(.scenicSettlementReview) + case "message_center": + return .destination(.messageCenter) + case "/scenic-queue", "queue_management": + return .destination(.queueManagement) + case "live_stream_management": + return .destination(.liveManagement) + case "live_album": + return .destination(.liveAlbum) + case "operating-area": + return .destination(.operatingArea) + case "pilot_cert": + return .destination(.pilotCertification) + 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 迁移和后续开发范围。" + ) + default: + return .placeholder(uri: uri, title: title.isEmpty ? self.title(for: uri) : title) + } + } + + /// 返回指定 URI 的默认标题。 + static func title(for uri: String) -> String { + titleMap[uri] ?? uri + } + + #if DEBUG + /// 返回调试页使用的全部已知首页菜单,不读取权限,便于预览迁移页面。 + static func debugAllMenuItems() -> [HomeMenuItem] { + let preferredOrder = [ + "space_settings", + "basic_info", + "album_list", + "album_trailer", + "wallet", + "payment_collection", + "payment_qr", + "payment_code", + "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", + "verification_order", + "photographer_orders", + "/scenic-order-manage", + "photographer_stats", + "pm", + "pm_manager", + "project_edit", + "location_report", + "registration_invitation", + "photographer_invite", + "invite_record", + "store", + "fly", + "pilot_cert", + "pilot_controller", + "/scenic-queue", + "queue_management", + "operating-area", + "deposit_order_detail", + "deposit_order", + "deposit_order_shooting_info", + "withdrawal_audit", + "location_report_history", + "more_functions" + ] + let orderedURIs = preferredOrder + titleMap.keys.sorted().filter { !preferredOrder.contains($0) } + return orderedURIs.map { uri in + HomeMenuItem(title: title(for: uri), uri: uri, iconSrc: nil) + } + } + #endif + + /// 在可用 URI 集合中选取规范形式。 + static func canonicalURI(for uri: String, availableURIs: Set) -> String { + if availableURIs.contains(uri) { + return uri + } + switch uri { + case "task_management": + return availableURIs.contains("task_management_editor") ? "task_management_editor" : uri + case "task_management_editor": + return availableURIs.contains("task_management") ? "task_management" : uri + case "registration_invitation": + return availableURIs.contains("photographer_invite") ? "photographer_invite" : uri + case "photographer_invite": + return availableURIs.contains("registration_invitation") ? "registration_invitation" : uri + case "pm": + return availableURIs.contains("project_edit") ? "project_edit" : uri + case "project_edit": + 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", "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", "project_edit": + return "项目管理" + case "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, .orders, .destination: + return true + case .unsupported, .placeholder: + return false + } + }, + unsupported: entries.filter { entry in + if case .unsupported = entry.route { return true } + return false + }, + unknown: entries.filter { entry in + if case .placeholder = entry.route { return true } + return false + } + ) + } + + /// 生成 Markdown 审计报告。 + static func markdownReport(audit: HomePermissionRouteAudit, generatedAt: Date = Date()) -> String { + var lines = [ + "# Home Permission Route Audit", + "", + "Generated: \(reportDateFormatter.string(from: generatedAt))", + "", + "Routable: \(audit.routable.count)", + "Unsupported: \(audit.unsupported.count)", + "Unknown: \(audit.unknown.count)", + "" + ] + + if audit.unknown.isEmpty { + lines.append("No unknown permission routes.") + } else { + lines.append("## Unknown Routes") + lines.append("") + lines.append("| URI | Title |") + lines.append("| --- | --- |") + lines.append(contentsOf: audit.unknown.map { "| \(escape($0.uri)) | \(escape($0.title)) |" }) + lines.append("") + lines.append("Action: map these URIs in `HomeMenuRouter`, confirm unsupported scope, or remove them before release.") + } + + if !audit.unsupported.isEmpty { + lines.append("") + lines.append("## Known Unsupported Routes") + lines.append("") + lines.append("| URI | Title | Reason |") + lines.append("| --- | --- | --- |") + lines.append(contentsOf: audit.unsupported.map { entry in + let reason: String + if case let .unsupported(_, _, value) = entry.route { + reason = value + } else { + reason = "" + } + return "| \(escape(entry.uri)) | \(escape(entry.title)) | \(escape(reason)) |" + }) + } + + return lines.joined(separator: "\n") + } + + private static var reportDateFormatter: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } + + /// 从权限树提取非空 URI,并按同义 URI 去重。 + private static func uniquePermissionItems(from items: [PermissionItem]) -> [PermissionItem] { + var seen = Set() + return flatten(items).filter { item in + guard !item.uri.isEmpty else { return false } + return seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted + } + } + + /// 递归展开权限树。 + private static func flatten(_ items: [PermissionItem]) -> [PermissionItem] { + items.flatMap { item in + [item] + flatten(item.children) + } + } + + /// 转义 Markdown 表格中的特殊字符。 + private static func escape(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "|", with: "\\|") + .replacingOccurrences(of: "\n", with: " ") + } +} + +/// 首页未知路由诊断工具,记录运行时无法识别的权限 URI。 +enum HomeRouteDiagnostics { + private static let logger = Logger(subsystem: "com.yuanzhixiang.suixinkan", category: "HomeRouting") + private static let defaultsKey = "home.routing.unknown.records" + private static let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + /// 记录一个未知首页 URI。 + static func recordUnknown(uri: String, title: String) { + logger.warning("Unknown home menu uri: \(uri, privacy: .public), title: \(title, privacy: .public)") + var records = unknownRoutes() + let now = Date() + if let index = records.firstIndex(where: { $0.uri == uri }) { + let current = records[index] + records[index] = UnknownHomeRouteRecord( + uri: current.uri, + title: title.isEmpty ? current.title : title, + firstSeenAt: current.firstSeenAt, + lastSeenAt: now, + count: current.count + 1 + ) + } else { + records.append(UnknownHomeRouteRecord(uri: uri, title: title, firstSeenAt: now, lastSeenAt: now, count: 1)) + } + save(records) + } + + /// 读取所有未知首页路由记录。 + static func unknownRoutes() -> [UnknownHomeRouteRecord] { + guard let data = UserDefaults.standard.data(forKey: defaultsKey), + let records = try? JSONDecoder().decode([UnknownHomeRouteRecord].self, from: data) + else { return [] } + return records + } + + /// 生成未知首页路由 Markdown 报告。 + static func unknownRouteReport(generatedAt: Date = Date()) -> String { + let records = unknownRoutes().sorted { lhs, rhs in + if lhs.count != rhs.count { + return lhs.count > rhs.count + } + return lhs.lastSeenAt > rhs.lastSeenAt + } + var lines = [ + "# Home Route Diagnostics", + "", + "Generated: \(dateFormatter.string(from: generatedAt))", + "" + ] + + guard !records.isEmpty else { + lines.append("No unknown home routes recorded.") + return lines.joined(separator: "\n") + } + + lines.append("| URI | Title | Count | First Seen | Last Seen |") + lines.append("| --- | --- | ---: | --- | --- |") + lines.append(contentsOf: records.map { record in + "| \(escape(record.uri)) | \(escape(record.title)) | \(record.count) | \(dateFormatter.string(from: record.firstSeenAt)) | \(dateFormatter.string(from: record.lastSeenAt)) |" + }) + lines.append("") + lines.append("Action: each URI above must be mapped in `HomeMenuRouter`, confirmed as unsupported scope, or explicitly accepted before release.") + return lines.joined(separator: "\n") + } + + /// 清空未知首页路由记录。 + static func resetUnknownRoutes() { + UserDefaults.standard.removeObject(forKey: defaultsKey) + } + + /// 保存未知首页路由记录。 + private static func save(_ records: [UnknownHomeRouteRecord]) { + guard let data = try? JSONEncoder().encode(records) else { return } + UserDefaults.standard.set(data, forKey: defaultsKey) + } + + /// 转义 Markdown 表格中的特殊字符。 + private static func escape(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "|", with: "\\|") + .replacingOccurrences(of: "\n", with: " ") + } +} diff --git a/suixinkan_ios/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan_ios/Features/Home/Services/HomeCommonMenuStore.swift new file mode 100644 index 0000000..fecb31d --- /dev/null +++ b/suixinkan_ios/Features/Home/Services/HomeCommonMenuStore.swift @@ -0,0 +1,91 @@ +// +// HomeCommonMenuStore.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 首页常用应用存储服务,负责按当前权限过滤和持久化常用 URI。 +struct HomeCommonMenuStore { + static let defaultStorageKey = "home.common.menu.uris" + static let defaultBaselineKey = "home.common.menu.android.baseline.v2" + + private let defaults: UserDefaults + private let storageKey: String + private let baselineKey: String + + /// 初始化常用应用存储服务,并允许测试注入独立 UserDefaults。 + init( + defaults: UserDefaults = .standard, + storageKey: String = Self.defaultStorageKey, + baselineKey: String = Self.defaultBaselineKey + ) { + self.defaults = defaults + self.storageKey = storageKey + self.baselineKey = baselineKey + } + + /// 读取常用 URI,首次使用时写入默认值,并始终按当前权限过滤。 + func load(menuItems: [HomeMenuItem]) -> [String] { + let availableURIs = Set(menuItems.map(\.uri)) + if !defaults.bool(forKey: baselineKey) { + let defaults = defaultCommonURIs(availableURIs: availableURIs) + save(defaults) + self.defaults.set(true, forKey: baselineKey) + return defaults + } + + let saved = defaults.stringArray(forKey: storageKey) ?? [] + let filtered = unique(saved.compactMap { uri -> String? in + let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) + return availableURIs.contains(resolved) ? resolved : nil + }) + save(filtered) + return filtered + } + + /// 将指定 URI 加入常用应用,按别名避免重复添加。 + func add(_ uri: String, current: [String], menuItems: [HomeMenuItem]) -> [String] { + let availableURIs = Set(menuItems.map(\.uri)) + let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) + guard availableURIs.contains(resolved) else { return current } + guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else { + return current + } + let next = current + [resolved] + save(next) + return next + } + + /// 从常用应用中移除指定 URI,同义 URI 会一起移除。 + func remove(_ uri: String, current: [String]) -> [String] { + let aliasKey = HomeMenuRouter.menuAliasKey(for: uri) + let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey } + save(next) + return next + } + + /// 保存常用 URI。 + func save(_ uris: [String]) { + defaults.set(unique(uris), forKey: storageKey) + } + + /// 生成默认常用应用 URI。 + private func defaultCommonURIs(availableURIs: Set) -> [String] { + let preferred = ["registration_invitation", "location_report", "pm_manager", "pm"] + return unique(preferred.compactMap { uri in + let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) + return availableURIs.contains(resolved) ? resolved : nil + }) + } + + /// 按 URI 别名去重,保留首次出现的 URI。 + private func unique(_ uris: [String]) -> [String] { + var seen = Set() + return uris.filter { uri in + seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted + } + } +} diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift b/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift new file mode 100644 index 0000000..5d18340 --- /dev/null +++ b/suixinkan_ios/Features/Home/ViewControllers/HomeMenuRouting.swift @@ -0,0 +1,59 @@ +// +// HomeMenuRouting.swift +// suixinkan +// + +import UIKit + +@MainActor +/// 首页菜单 UIKit 路由,将权限 URI 解析结果映射为 Tab 切换或 Navigation push。 +enum HomeMenuRouting { + + /// 打开首页菜单项对应的目标。 + static func openMenu(_ item: HomeMenuItem, from viewController: UIViewController) { + openRoute(HomeMenuRouter.resolve(uri: item.uri, title: item.title), from: viewController) + } + + /// 打开首页 URI 解析结果。 + static func openRoute(_ route: HomeMenuResolvedRoute, from viewController: UIViewController) { + let services = AppServices.shared + switch route { + case .tab(let tab): + services.appRouter.select(tab) + case .orders(let entry): + services.appRouter.selectOrders(entry: entry) + case .destination(let homeRoute): + push(homeRoute, from: viewController) + case .unsupported(let uri, let title, _): + pushPlaceholder(title: title, uri: uri, from: viewController) + case .placeholder(let uri, let title): + HomeRouteDiagnostics.recordUnknown(uri: uri, title: title) + pushPlaceholder(title: title, uri: uri, from: viewController) + } + } + + /// Push 首页二级路由页面。 + static func push(_ route: HomeRoute, from viewController: UIViewController) { + let target = AppRouteViewControllerFactory.makeViewController(for: route, services: AppServices.shared) + viewController.navigationController?.pushViewController(target, animated: true) + } + + /// Push 订单模块路由页面。 + static func pushOrders(_ route: OrdersRoute, from viewController: UIViewController) { + let target = AppRouteViewControllerFactory.makeViewController(for: .orders(route), services: AppServices.shared) + viewController.navigationController?.pushViewController(target, animated: true) + } + + /// Push 个人中心二级路由页面。 + static func pushProfile(_ route: ProfileRoute, from viewController: UIViewController) { + let target = AppRouteViewControllerFactory.makeViewController(for: .profile(route), services: AppServices.shared) + viewController.navigationController?.pushViewController(target, animated: true) + } + + private static func pushPlaceholder(title: String, uri: String, from viewController: UIViewController) { + viewController.navigationController?.pushViewController( + FeaturePlaceholderViewController(title: title, uri: uri), + animated: true + ) + } +} diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift b/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift new file mode 100644 index 0000000..7d9a33f --- /dev/null +++ b/suixinkan_ios/Features/Home/ViewControllers/HomeMoreFunctionsViewController.swift @@ -0,0 +1,260 @@ +// +// HomeMoreFunctionsViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 首页全部功能页,支持常用应用增删和更多功能网格展示。 +final class HomeMoreFunctionsViewController: UIViewController { + + private let viewModel = HomeViewModel() + private let commonMenuStore = HomeCommonMenuStore() + private var commonURIs: [String] = [] + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .grouped) + table.backgroundColor = AppDesignUIKit.pageBackground + table.separatorStyle = .none + table.dataSource = self + table.delegate = self + table.register(HomeMoreMenuGridCell.self, forCellReuseIdentifier: HomeMoreMenuGridCell.reuseID) + return table + }() + + override func viewDidLoad() { + super.viewDidLoad() + title = "全部功能" + view.backgroundColor = AppDesignUIKit.pageBackground + navigationItem.largeTitleDisplayMode = .never + + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + viewModel.onChange = { [weak self] in + self?.tableView.reloadData() + } + rebuildMenus() + + appServices.permissionContext.onChange = { [weak self] in + self?.rebuildMenus() + } + } + + private func rebuildMenus() { + let services = appServices + viewModel.buildMenus( + from: services.permissionContext.rolePermissions, + currentRoleId: services.permissionContext.currentRole?.id + ) + commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems) + tableView.reloadData() + } + + private var commonItems: [HomeMenuItem] { + commonURIs.compactMap { menuItem(for: $0) } + } + + private var moreItems: [HomeMenuItem] { + viewModel.menuItems.filter { !isCommonURI($0.uri) } + } + + 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 toggleCommon(_ item: HomeMenuItem, isCommon: Bool) { + if isCommon { + commonURIs = commonMenuStore.remove(item.uri, current: commonURIs) + } else { + commonURIs = commonMenuStore.add(item.uri, current: commonURIs, menuItems: viewModel.menuItems) + } + tableView.reloadData() + } + + private func openMenu(_ item: HomeMenuItem) { + let route = HomeMenuRouter.resolve(uri: item.uri, title: item.title) + if case .destination(let homeRoute) = route, homeRoute == .moreFunctions { return } + HomeMenuRouting.openRoute(route, from: self) + } +} + +extension HomeMoreFunctionsViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { 2 } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? "常用应用" : "更多功能" + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: HomeMoreMenuGridCell.reuseID, for: indexPath) as! HomeMoreMenuGridCell + let isCommon = indexPath.section == 0 + let items = isCommon ? commonItems : moreItems + cell.configure(items: items, isCommon: isCommon) { [weak self] item in + self?.openMenu(item) + } onToggle: { [weak self] item in + self?.toggleCommon(item, isCommon: isCommon) + } + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let count = indexPath.section == 0 ? commonItems.count : moreItems.count + let rows = max(1, Int(ceil(Double(count) / 3.0))) + return CGFloat(rows) * 124 + 8 + } +} + +private final class HomeMoreMenuGridCell: UITableViewCell { + static let reuseID = "HomeMoreMenuGridCell" + + private var items: [HomeMenuItem] = [] + private var isCommon = false + private var onSelect: ((HomeMenuItem) -> Void)? + private var onToggle: ((HomeMenuItem) -> Void)? + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = 14 + layout.minimumLineSpacing = AppMetrics.Spacing.mediumLarge + let view = UICollectionView(frame: .zero, collectionViewLayout: layout) + view.backgroundColor = .clear + view.isScrollEnabled = false + view.dataSource = self + view.delegate = self + view.register(HomeMoreMenuItemCell.self, forCellWithReuseIdentifier: HomeMoreMenuItemCell.reuseID) + return view + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + selectionStyle = .none + contentView.addSubview(collectionView) + collectionView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppMetrics.Spacing.mediumLarge) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure( + items: [HomeMenuItem], + isCommon: Bool, + onSelect: @escaping (HomeMenuItem) -> Void, + onToggle: @escaping (HomeMenuItem) -> Void + ) { + self.items = items + self.isCommon = isCommon + self.onSelect = onSelect + self.onToggle = onToggle + collectionView.reloadData() + } +} + +extension HomeMoreMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + items.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMoreMenuItemCell.reuseID, for: indexPath) as! HomeMoreMenuItemCell + let item = items[indexPath.item] + cell.configure(item: item, isCommon: isCommon) { [weak self] in + self?.onToggle?(item) + } + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + onSelect?(items[indexPath.item]) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let width = (collectionView.bounds.width - 28) / 3 + return CGSize(width: width, height: 112) + } +} + +private final class HomeMoreMenuItemCell: UICollectionViewCell { + static let reuseID = "HomeMoreMenuItemCell" + + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let toggleButton = UIButton(type: .system) + private var onToggle: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.backgroundColor = .white + contentView.layer.cornerRadius = 8 + + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.callout) + titleLabel.textColor = UIColor(hex: 0x252525) + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 1 + titleLabel.adjustsFontSizeToFitWidth = true + + toggleButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside) + contentView.addSubview(toggleButton) + + let stack = UIStackView(arrangedSubviews: [iconView, titleLabel]) + stack.axis = .vertical + stack.spacing = AppMetrics.Spacing.mediumLarge + 1 + stack.alignment = .center + contentView.addSubview(stack) + + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(4) + } + iconView.snp.makeConstraints { make in + make.width.height.equalTo(34) + } + toggleButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(2) + make.width.height.equalTo(28) + } + iconView.contentMode = .scaleAspectFit + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(item: HomeMenuItem, isCommon: Bool, onToggle: @escaping () -> Void) { + self.onToggle = onToggle + titleLabel.text = item.title + let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri)) + iconView.loadRemoteImage(urlString: item.iconSrc, contentMode: .scaleAspectFit, placeholder: symbol) + iconView.tintColor = AppDesign.primary + let iconName = isCommon ? "minus.circle.fill" : "plus.circle.fill" + toggleButton.setImage(UIImage(systemName: iconName), for: .normal) + toggleButton.tintColor = isCommon ? UIColor(hex: 0xFF1111) : AppDesignUIKit.primary + } + + @objc private func toggleTapped() { + onToggle?() + } +} diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift b/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift new file mode 100644 index 0000000..08b5d65 --- /dev/null +++ b/suixinkan_ios/Features/Home/ViewControllers/HomePlaceholderViewController.swift @@ -0,0 +1,63 @@ +// +// HomePlaceholderViewController.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import SnapKit +import UIKit + +/// 首页迁移占位页,用于尚未完成 UIKit 迁移的入口。 +final class HomePlaceholderViewController: UIViewController { + private let pageTitle: String + private let uri: String? + + init(title: String, uri: String? = nil) { + pageTitle = title + self.uri = uri + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = pageTitle + view.backgroundColor = UIColor(hex: 0xF5F7FA) + + let iconView = UIImageView(image: UIImage(systemName: "square.grid.2x2")) + iconView.tintColor = AppDesign.primary + iconView.contentMode = .scaleAspectFit + + let titleLabel = UILabel() + titleLabel.text = pageTitle + titleLabel.font = .systemFont(ofSize: 20, weight: .semibold) + titleLabel.textColor = AppDesign.textPrimary + titleLabel.textAlignment = .center + + let uriLabel = UILabel() + uriLabel.text = uri + uriLabel.font = .systemFont(ofSize: 14) + uriLabel.textColor = AppDesign.textSecondary + uriLabel.textAlignment = .center + uriLabel.numberOfLines = 2 + uriLabel.isHidden = uri?.isEmpty != false + + let stack = UIStackView(arrangedSubviews: [iconView, titleLabel, uriLabel]) + stack.axis = .vertical + stack.spacing = 12 + stack.alignment = .center + view.addSubview(stack) + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(24) + } + iconView.snp.makeConstraints { make in + make.width.height.equalTo(44) + } + } +} diff --git a/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift b/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift new file mode 100644 index 0000000..c85a52d --- /dev/null +++ b/suixinkan_ios/Features/Home/ViewControllers/HomeViewController.swift @@ -0,0 +1,583 @@ +// +// HomeViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 首页工作台,展示景区头部、工作状态、位置上报卡片和常用应用网格。 +final class HomeViewController: UIViewController { + + private let viewModel = HomeViewModel() + private let commonMenuStore = HomeCommonMenuStore() + private var commonURIs: [String] = [] + private var isOnline = false + private var reminderMinutes = 0 + private var secondsUntilReport = 0 + private var countdownTimer: Timer? + + private let minimalTopRoleIds: Set = [46, 47, 52, 53, 54] + + private lazy var scenicButton: UIButton = { + var config = UIButton.Configuration.plain() + config.baseForegroundColor = UIColor(hex: 0x333333) + config.image = UIImage(systemName: "chevron.down")?.withConfiguration( + UIImage.SymbolConfiguration(pointSize: AppMetrics.FontSize.subheadline, weight: .bold) + ) + config.imagePlacement = .trailing + config.imagePadding = AppMetrics.Spacing.xSmall + config.contentInsets = .zero + let button = UIButton(configuration: config) + button.contentHorizontalAlignment = .leading + button.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside) + return button + }() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .grouped) + table.backgroundColor = AppDesignUIKit.pageBackground + table.separatorStyle = .none + table.showsVerticalScrollIndicator = false + table.dataSource = self + table.delegate = self + table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID) + table.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + return table + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = AppDesignUIKit.pageBackground + setupTopBar() + setupTableView() + bindViewModel() + rebuildMenus() + observeContextChanges() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + startCountdownTimerIfNeeded() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + countdownTimer?.invalidate() + countdownTimer = nil + } + + private func setupTopBar() { + let topBar = UIView() + topBar.backgroundColor = .white + view.addSubview(topBar) + topBar.addSubview(scenicButton) + + topBar.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.height.equalTo(78) + } + scenicButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + make.centerY.equalToSuperview() + } + updateScenicTitle() + } + + private func setupTableView() { + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(78) + make.leading.trailing.bottom.equalToSuperview() + } + } + + private func bindViewModel() { + viewModel.onChange = { [weak self] in + self?.tableView.reloadData() + } + } + + private func observeContextChanges() { + let services = appServices + services.permissionContext.onChange = { [weak self] in + self?.rebuildMenus() + } + services.accountContext.onChange = { [weak self] in + self?.updateScenicTitle() + self?.tableView.reloadData() + } + } + + private func rebuildMenus() { + let services = appServices + viewModel.buildMenus( + from: services.permissionContext.rolePermissions, + currentRoleId: services.permissionContext.currentRole?.id + ) + commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems) + tableView.reloadData() + } + + private func updateScenicTitle() { + let name = appServices.accountContext.currentScenic?.name ?? "请选择景区" + scenicButton.configuration?.attributedTitle = AttributedString( + name, + attributes: AttributeContainer([ + .font: UIFont.systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold) + ]) + ) + } + + private var currentRoleId: Int? { + appServices.permissionContext.currentRole?.id + } + + private var shouldShowWorkStatus: Bool { + guard let currentRoleId else { return true } + return !minimalTopRoleIds.contains(currentRoleId) + } + + private var isStoreManager: Bool { + currentRoleId == 46 + } + + private var displayMenuItems: [HomeMenuItem] { + let selected = commonURIs.compactMap { menuItem(for: $0) } + let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected + return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)] + } + + 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 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 func startCountdownTimerIfNeeded() { + countdownTimer?.invalidate() + guard isOnline else { return } + countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + guard let self, self.isOnline, self.secondsUntilReport > 0 else { return } + self.secondsUntilReport -= 1 + self.tableView.reloadSections(IndexSet(integer: 0), with: .none) + } + } + + @objc private func scenicTapped() { + HomeMenuRouting.push(.scenicSelection, from: self) + } + + @objc private func onlineTapped() { + let message = isOnline + ? "是否确认切换为离线状态?离线后将暂停位置上报。" + : "是否确认切换为在线状态?在线后将开始位置上报和计时。" + let alert = UIAlertController(title: "切换在线状态", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in + guard let self else { return } + self.isOnline.toggle() + if self.isOnline { + self.secondsUntilReport = 7_200 + self.startCountdownTimerIfNeeded() + } else { + self.countdownTimer?.invalidate() + } + self.tableView.reloadSections(IndexSet(integer: 0), with: .none) + }) + present(alert, animated: true) + } + + @objc private func reminderTapped() { + let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet) + for minute in [0, 5, 10, 15, 30] { + let title = minute == 0 ? "不提醒" : "\(minute)分钟" + sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in + self?.reminderMinutes = minute + self?.tableView.reloadSections(IndexSet(integer: 0), with: .none) + }) + } + sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(sheet, animated: true) + } + + @objc private func locationReportTapped() { + HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self) + } + + @objc private func paymentTapped() { + HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self) + } + + @objc private func taskCreateTapped() { + HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self) + } +} + +// MARK: - UITableView + +extension HomeViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + var count = 1 + if shouldShowWorkStatus { count += 2 } + if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 } + return count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + 1 + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + if section == tableView.numberOfSections - 1 { + return "常用应用" + } + return nil + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.section == tableView.numberOfSections - 1 { + let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell + cell.configure(items: displayMenuItems) { [weak self] item in + self.flatMap { HomeMenuRouting.openMenu(item, from: $0) } + } + return cell + } + + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + cell.selectionStyle = .none + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + + if shouldShowWorkStatus { + if indexPath.section == 0 { + cell.contentView.addSubview(makeStatusCard()) + } else if indexPath.section == 1 { + cell.contentView.addSubview(makeLocationCard()) + } else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore { + cell.contentView.addSubview(makeStoreCard(store)) + } else { + cell.contentView.addSubview(makeQuickActionsRow()) + } + } else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 { + cell.contentView.addSubview(makeStoreCard(store)) + } + + cell.contentView.subviews.first?.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets( + top: AppMetrics.Spacing.xxSmall, + left: AppMetrics.Spacing.pageHorizontal, + bottom: AppMetrics.Spacing.xxSmall, + right: AppMetrics.Spacing.pageHorizontal + )) + } + cell.backgroundColor = .clear + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + if indexPath.section == tableView.numberOfSections - 1 { return 240 } + if shouldShowWorkStatus { + switch indexPath.section { + case 0: return 100 + case 1: return 148 + case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88 + default: return 118 + } + } + if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 } + return UITableView.automaticDimension + } + + private func makeStatusCard() -> UIView { + let card = makeCardView() + + let onlineButton = UIButton(type: .system) + onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal) + onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) + onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal) + onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4) + onlineButton.layer.cornerRadius = 4 + onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) + onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside) + + let clockLabel = UILabel() + clockLabel.text = " \(countdownDisplay)" + clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) + clockLabel.textColor = AppDesignUIKit.primary + + let reminderButton = UIButton(type: .system) + reminderButton.setTitle(" \(reminderText)", for: .normal) + reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) + reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal) + reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton]) + stack.axis = .horizontal + stack.distribution = .equalSpacing + stack.alignment = .center + card.addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(15) + } + return card + } + + private func makeLocationCard() -> UIView { + let card = makeCardView() + + let titleLabel = UILabel() + titleLabel.text = "立即上报" + titleLabel.font = .systemFont(ofSize: 22, weight: .bold) + titleLabel.textColor = UIColor(hex: 0x333333) + + let subtitleLabel = UILabel() + subtitleLabel.text = "您已进入打卡范围" + subtitleLabel.font = .systemFont(ofSize: 15) + subtitleLabel.textColor = UIColor(hex: 0x999999) + + let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + textStack.axis = .vertical + textStack.spacing = AppMetrics.Spacing.xxSmall + + let actionButton = UIButton(type: .system) + actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal) + actionButton.tintColor = .white + actionButton.backgroundColor = AppDesignUIKit.primary + actionButton.layer.cornerRadius = 46 + actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside) + + card.addSubview(textStack) + card.addSubview(actionButton) + textStack.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(15) + make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12) + } + actionButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(15) + make.centerY.equalToSuperview() + make.width.height.equalTo(92) + } + return card + } + + private func makeQuickActionsRow() -> UIView { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = AppMetrics.Spacing.small + stack.distribution = .fillEqually + + stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped))) + stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped))) + stack.addArrangedSubview(quickActionButton( + icon: isOnline ? "wifi" : "wifi.slash", + title: isOnline ? "在线" : "离线", + action: #selector(onlineTapped), + active: isOnline + )) + return stack + } + + private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView { + let card = makeCardView() + card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white + + let iconView = UIImageView(image: UIImage(systemName: icon)) + iconView.tintColor = AppDesignUIKit.primary + iconView.contentMode = .scaleAspectFit + + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: AppMetrics.FontSize.body) + label.textAlignment = .center + + let stack = UIStackView(arrangedSubviews: [iconView, label]) + stack.axis = .vertical + stack.spacing = AppMetrics.Spacing.xSmall + stack.alignment = .center + card.addSubview(stack) + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + } + iconView.snp.makeConstraints { make in + make.height.equalTo(34) + } + + let button = UIButton(type: .custom) + button.addTarget(self, action: action, for: .touchUpInside) + card.addSubview(button) + button.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + card.snp.makeConstraints { make in + make.height.equalTo(102) + } + return card + } + + private func makeStoreCard(_ store: BusinessScope) -> UIView { + let card = makeCardView() + + let nameLabel = UILabel() + nameLabel.text = store.name + nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold) + + let scenicLabel = UILabel() + scenicLabel.text = appServices.accountContext.currentScenic?.name + scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + scenicLabel.textColor = UIColor(hex: 0x7B8EAA) + scenicLabel.numberOfLines = 2 + + let statusLabel = UILabel() + statusLabel.text = "营业中" + statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + statusLabel.textColor = UIColor(hex: 0x22C55E) + statusLabel.backgroundColor = UIColor(hex: 0xF0FDF4) + statusLabel.layer.cornerRadius = 4 + statusLabel.clipsToBounds = true + statusLabel.textAlignment = .center + + let textStack = UIStackView(arrangedSubviews: [nameLabel, scenicLabel]) + textStack.axis = .vertical + textStack.spacing = AppMetrics.Spacing.xSmall + + card.addSubview(textStack) + card.addSubview(statusLabel) + textStack.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium) + make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8) + } + statusLabel.snp.makeConstraints { make in + make.trailing.top.equalToSuperview().inset(AppMetrics.Spacing.medium) + make.width.greaterThanOrEqualTo(52) + make.height.equalTo(22) + } + return card + } +} + +// MARK: - Menu Grid Cell + +private final class HomeMenuGridCell: UITableViewCell { + static let reuseID = "HomeMenuGridCell" + + private var onSelect: ((HomeMenuItem) -> Void)? + private var items: [HomeMenuItem] = [] + + private lazy var collectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.minimumInteritemSpacing = 15 + layout.minimumLineSpacing = 15 + let view = UICollectionView(frame: .zero, collectionViewLayout: layout) + view.backgroundColor = .clear + view.isScrollEnabled = false + view.dataSource = self + view.delegate = self + view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID) + return view + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + selectionStyle = .none + contentView.addSubview(collectionView) + collectionView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) + make.height.equalTo(220) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) { + self.items = items + self.onSelect = onSelect + collectionView.reloadData() + } +} + +extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + items.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell + cell.configure(item: items[indexPath.item]) + return cell + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + onSelect?(items[indexPath.item]) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let width = (collectionView.bounds.width - 30) / 3 + return CGSize(width: width, height: 102) + } +} + +private final class HomeMenuItemCell: UICollectionViewCell { + static let reuseID = "HomeMenuItemCell" + + private let iconView = UIImageView() + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.backgroundColor = .white + contentView.layer.cornerRadius = 8 + + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body) + titleLabel.textColor = UIColor(hex: 0x4B5563) + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 1 + titleLabel.adjustsFontSizeToFitWidth = true + + let stack = UIStackView(arrangedSubviews: [iconView, titleLabel]) + stack.axis = .vertical + stack.spacing = AppMetrics.Spacing.small + stack.alignment = .center + contentView.addSubview(stack) + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(4) + } + iconView.snp.makeConstraints { make in + make.width.height.equalTo(30) + } + iconView.contentMode = .scaleAspectFit + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(item: HomeMenuItem) { + titleLabel.text = item.title + let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri)) + iconView.loadRemoteImage(urlString: item.iconSrc, contentMode: .scaleAspectFit, placeholder: symbol) + iconView.tintColor = AppDesign.primary + } +} diff --git a/suixinkan_ios/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan_ios/Features/Home/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..0b8b659 --- /dev/null +++ b/suixinkan_ios/Features/Home/ViewModels/HomeViewModel.swift @@ -0,0 +1,115 @@ +// +// HomeViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 首页 ViewModel,根据当前角色权限构建首页菜单列表。 +final class HomeViewModel { + var onChange: (() -> Void)? + private(set) var menuItems: [HomeMenuItem] = [] { didSet { onChange?() } } + + /// 菜单排序权重,与旧工程和 Android 端保持一致。 + private let preferredOrder: [String] = [ + "space_settings", + "album_list", + "album_trailer", + "wallet", + "cloud_management", + "cloud_storage_transit", + "asset_management", + "material_upload", + "task_management", + "schedule_management", + "system_settings", + "message_center", + "checkin_points", + "sample_management", + "sample_upload", + "live_stream_management", + "scenicselection", + "scenicapplication", + "permission_apply", + "permission_apply_status", + "scenic_settlement", + "scenic_settlement_review", + "verification_order", + "live_album", + "pm", + "location_report", + "registration_invitation", + "store", + "fly", + "pilot_cert", + "task_management_editor", + "deposit_order_detail", + "deposit_order", + "pm_manager", + "pilot_controller", + "/scenic-queue", + "queue_management", + "operating-area", + "/scenic-order-manage", + "photographer_orders" + ] + + /// 从角色权限树扁平化出菜单项,按固定顺序排序并按 URI 别名去重。 + func buildMenus(from permissions: [RolePermissionResponse], currentRoleId: Int?) { + let source: RolePermissionResponse? + if let currentRoleId { + source = permissions.first { $0.role.id == currentRoleId } + } else { + source = permissions.first + } + + guard let source else { + menuItems = [] + return + } + + let permissionItems = flatten(source.role.permission) + var seen = Set() + var deduplicated: [PermissionItem] = [] + for item in permissionItems where !item.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if seen.insert(HomeMenuRouter.menuAliasKey(for: item.uri)).inserted { + deduplicated.append(item) + } + } + + let preferredIndexMap = Dictionary(uniqueKeysWithValues: preferredOrder.enumerated().map { ($1, $0) }) + let sortedItems = deduplicated.sorted { lhs, rhs in + let lhsRank = preferredIndexMap[lhs.uri] ?? Int.max + let rhsRank = preferredIndexMap[rhs.uri] ?? Int.max + if lhsRank != rhsRank { + return lhsRank < rhsRank + } + let lhsIndex = deduplicated.firstIndex(where: { $0.id == lhs.id }) ?? Int.max + let rhsIndex = deduplicated.firstIndex(where: { $0.id == rhs.id }) ?? Int.max + return lhsIndex < rhsIndex + } + + menuItems = sortedItems.map { item in + HomeMenuItem( + title: item.name.isEmpty ? HomeMenuRouter.title(for: item.uri) : item.name, + uri: item.uri, + iconSrc: item.iconSrc + ) + } + } + + /// 返回指定 URI 的展示标题。 + func title(for uri: String) -> String { + HomeMenuRouter.title(for: uri) + } + + /// 递归展开权限树。 + private func flatten(_ items: [PermissionItem]) -> [PermissionItem] { + items.flatMap { item in + [item] + flatten(item.children) + } + } +} diff --git a/suixinkan_ios/Features/Invite/API/InviteAPI.swift b/suixinkan_ios/Features/Invite/API/InviteAPI.swift new file mode 100644 index 0000000..f832ece --- /dev/null +++ b/suixinkan_ios/Features/Invite/API/InviteAPI.swift @@ -0,0 +1,48 @@ +// +// InviteAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 邀请服务协议,抽象邀请信息和邀请记录接口。 +@MainActor +protocol InviteServing { + /// 获取邀请二维码、邀请码和规则信息。 + func inviteInfo() async throws -> InviteInfoResponse + + /// 获取邀请用户列表。 + func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem] +} + +/// 邀请 API,封装摄影师邀请相关接口。 +@MainActor +final class InviteAPI: InviteServing { + private let client: APIClient + + /// 初始化邀请 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取邀请二维码、邀请码和规则信息。 + func inviteInfo() async throws -> InviteInfoResponse { + try await client.send(APIRequest(method: .get, path: "/api/yf-handset-app/photog/invite-info")) + } + + /// 获取邀请用户列表。 + func inviteUserList(page: Int = 1, pageSize: Int = 20) async throws -> [InviteUserItem] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/invite/user-list", + queryItems: [ + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } +} diff --git a/suixinkan_ios/Features/Invite/Invite.md b/suixinkan_ios/Features/Invite/Invite.md new file mode 100644 index 0000000..f39cba5 --- /dev/null +++ b/suixinkan_ios/Features/Invite/Invite.md @@ -0,0 +1,30 @@ +# Invite 模块业务逻辑 + +## 模块职责 + +Invite 模块负责首页 `registration_invitation`、`photographer_invite` 和 `invite_record` 入口。 + +邀请页展示邀请码、邀请链接、二维码和规则;邀请记录页展示邀请用户和奖励记录。模块不保存业务状态到全局对象。 + +## 邀请页 + +`PhotographerInviteViewModel` 通过 `InviteAPI.inviteInfo()` 获取邀请信息,并使用 CoreImage 在本地生成二维码图片。二维码图片只用于当前页面展示,不落盘。 + +复制邀请链接时只由 View 层写入剪贴板,ViewModel 不关心系统剪贴板状态。 + +## 邀请记录 + +`InviteRecordViewModel` 提供“邀请记录 / 奖励记录”分段: +- 邀请记录来自 `InviteAPI.inviteUserList`。 +- 奖励记录复用 `WalletAPI.walletEarningDetail`。 +- 顶部奖励汇总复用 `WalletAPI.walletSummary(type: 2)`。 + +分页状态、筛选分段和展示行只存在 ViewModel 内存中。 + +## 接口边界 + +`InviteAPI` 只封装邀请信息和邀请用户列表。钱包收益和提现入口继续由 Wallet 模块负责,Invite 模块只调用其公开服务协议,不持有钱包业务状态。 + +## 缓存边界 + +邀请码、邀请记录、奖励记录和二维码都不落盘。后续如果需要分享图片,应在分享流程中临时生成。 diff --git a/suixinkan_ios/Features/Invite/Models/InviteModels.swift b/suixinkan_ios/Features/Invite/Models/InviteModels.swift new file mode 100644 index 0000000..c74d25b --- /dev/null +++ b/suixinkan_ios/Features/Invite/Models/InviteModels.swift @@ -0,0 +1,156 @@ +// +// InviteModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 邀请信息响应实体,表示邀请码、邀请链接和规则文案。 +struct InviteInfoResponse: Decodable, Equatable { + let enableInvite: Bool + let inviteCode: String + let inviteUrl: String + let description: [String] + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case enableInvite = "enable_invite" + case inviteCode = "invite_code" + case inviteUrl = "invite_url" + case description + } + + /// 宽松解码邀请信息。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enableInvite = try container.decodeInviteLossyBool(forKey: .enableInvite) ?? true + inviteCode = try container.decodeInviteLossyString(forKey: .inviteCode) + inviteUrl = try container.decodeInviteLossyString(forKey: .inviteUrl) + description = (try? container.decodeIfPresent([String].self, forKey: .description)) ?? [] + } +} + +/// 邀请用户实体,表示邀请记录里的摄影师。 +struct InviteUserItem: Decodable, Identifiable, Hashable { + let id: Int + let realName: String + let phone: String + let avatar: String + let createdAt: String + let inviteLevel: Int + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case realName = "real_name" + case phone + case avatar + case createdAt = "created_at" + case inviteLevel = "invite_level" + } + + /// 宽松解码邀请用户字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeInviteLossyInt(forKey: .id) ?? 0 + realName = try container.decodeInviteLossyString(forKey: .realName) + phone = try container.decodeInviteLossyString(forKey: .phone) + avatar = try container.decodeInviteLossyString(forKey: .avatar) + createdAt = try container.decodeInviteLossyString(forKey: .createdAt) + inviteLevel = try container.decodeInviteLossyInt(forKey: .inviteLevel) ?? 0 + } +} + +/// 邀请记录页面展示行实体,统一邀请用户和奖励明细的展示字段。 +struct InviteDisplayRow: Identifiable, Equatable { + let id: String + let title: String + let subtitle: String + let amount: String + let time: String + let phone: String + let avatar: String + let inviteLevel: Int + + /// 邀请层级文案。 + var levelText: String { + inviteLevel == 1 ? "一级" : "二级" + } + + /// 头像占位文案。 + var avatarPlaceholder: String { + if let first = title.first { + return String(first) + } + if let first = phone.first { + return String(first) + } + return "?" + } +} + +/// 邀请记录分段类型。 +enum InviteRecordTab: CaseIterable, Equatable { + case invite + case reward + + /// 分段标题。 + var title: String { + switch self { + case .invite: return "邀请记录" + case .reward: return "奖励记录" + } + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeInviteLossyString(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 ? "1" : "0" + } + return "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeInviteLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(text) ?? Double(text).map(Int.init) + } + return nil + } + + /// 将 String、数字和 Bool 宽松解码为 Bool。 + func decodeInviteLossyBool(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 text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["1", "true", "yes"].contains(text) { return true } + if ["0", "false", "no"].contains(text) { return false } + } + return nil + } +} diff --git a/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift b/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift new file mode 100644 index 0000000..c0d3132 --- /dev/null +++ b/suixinkan_ios/Features/Invite/ViewControllers/InviteViewControllers.swift @@ -0,0 +1,148 @@ +// +// InviteViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import SnapKit +import UIKit + +/// 摄影师邀请页。 +final class PhotographerInviteViewController: UIViewController { + private let services = AppServices.shared + private let viewModel = PhotographerInviteViewModel() + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + private let codeLabel = UILabel() + private let urlLabel = UILabel() + private let qrImageView = UIImageView() + private let rulesLabel = UILabel() + private let activityIndicator = UIActivityIndicatorView(style: .medium) + + override func viewDidLoad() { + super.viewDidLoad() + title = "邀请摄影师" + view.backgroundColor = UIColor(hex: 0xF5F7FA) + setupUI() + viewModel.onChange = { [weak self] in self?.render() } + Task { await viewModel.reload(api: services.inviteAPI) } + } + + private func setupUI() { + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(title: "复制码", style: .plain, target: self, action: #selector(copyCode)), + UIBarButtonItem(title: "复制链接", style: .plain, target: self, action: #selector(copyURL)) + ] + + contentStack.axis = .vertical + contentStack.spacing = 12 + scrollView.addSubview(contentStack) + view.addSubview(scrollView) + view.addSubview(activityIndicator) + + scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + make.width.equalTo(scrollView.snp.width).offset(-32) + } + activityIndicator.snp.makeConstraints { $0.center.equalToSuperview() } + + codeLabel.font = .systemFont(ofSize: 22, weight: .bold) + urlLabel.font = .systemFont(ofSize: 14) + urlLabel.numberOfLines = 0 + urlLabel.textColor = AppDesign.textSecondary + qrImageView.contentMode = .scaleAspectFit + qrImageView.snp.makeConstraints { $0.height.equalTo(220) } + rulesLabel.numberOfLines = 0 + rulesLabel.font = .systemFont(ofSize: 14) + rulesLabel.textColor = AppDesign.textSecondary + + contentStack.addArrangedSubview(codeLabel) + contentStack.addArrangedSubview(urlLabel) + contentStack.addArrangedSubview(qrImageView) + contentStack.addArrangedSubview(rulesLabel) + } + + private func render() { + activityIndicator.isHidden = !viewModel.loading + if viewModel.loading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } + codeLabel.text = viewModel.inviteCode.isEmpty ? "邀请码加载中..." : "邀请码:\(viewModel.inviteCode)" + urlLabel.text = viewModel.inviteUrl + qrImageView.image = viewModel.qrImage + rulesLabel.text = viewModel.rules.joined(separator: "\n") + if let error = viewModel.errorMessage { + rulesLabel.text = (rulesLabel.text ?? "") + "\n\n" + error + } + } + + @objc private func copyCode() { viewModel.copyInviteCode() } + @objc private func copyURL() { viewModel.copyInviteUrl() } +} + +extension PhotographerInviteViewModel: ViewModelBindable {} + +/// 邀请记录页。 +final class InviteRecordViewController: ModuleTableViewController { + private let viewModel = InviteRecordViewModel() + private let summaryLabel = UILabel() + + override func viewDidLoad() { + title = "邀请记录" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "奖励明细", + style: .plain, + target: self, + action: #selector(toggleTab) + ) + super.viewDidLoad() + setupHeader() + viewModel.onChange = { [weak self] in + self?.updateSummary() + self?.reloadTable() + } + } + + private func setupHeader() { + summaryLabel.font = .systemFont(ofSize: 14) + summaryLabel.textColor = AppDesign.textSecondary + summaryLabel.numberOfLines = 0 + summaryLabel.textAlignment = .center + summaryLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 56) + tableView.tableHeaderView = summaryLabel + } + + override func tableRowCount() -> Int { + viewModel.displayRows.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let row = viewModel.displayRows[indexPath.row] + cell.configure( + title: row.title, + subtitle: row.subtitle, + detail: row.amount.isEmpty ? row.time : "\(row.amount) · \(row.time)" + ) + } + + override func reloadContent() async { + await viewModel.reload( + inviteAPI: services.inviteAPI, + walletAPI: services.walletAPI, + refresh: true + ) + updateSummary() + } + + private func updateSummary() { + summaryLabel.text = "累计奖励 \(viewModel.totalRewardText) · 可提现 \(viewModel.withdrawableText)" + navigationItem.rightBarButtonItem?.title = viewModel.tab == .invite ? "奖励明细" : "邀请用户" + } + + @objc private func toggleTab() { + Task { + let next: InviteRecordTab = viewModel.tab == .invite ? .reward : .invite + await viewModel.selectTab(next, inviteAPI: services.inviteAPI, walletAPI: services.walletAPI) + } + } +} diff --git a/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift b/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift new file mode 100644 index 0000000..881db5f --- /dev/null +++ b/suixinkan_ios/Features/Invite/ViewModels/InviteViewModels.swift @@ -0,0 +1,203 @@ +// +// InviteViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import CoreImage.CIFilterBuiltins +import Foundation +import UIKit + +/// 邀请页 ViewModel,负责加载邀请信息、生成二维码和复制内容。 +@MainActor +final class PhotographerInviteViewModel { + var onChange: (() -> Void)? + private(set) var loading = false { didSet { onChange?() } } + private(set) var inviteCode = "" { didSet { onChange?() } } + private(set) var inviteUrl = "" { didSet { onChange?() } } + private(set) var rules: [String] = [] { didSet { onChange?() } } + private(set) var qrImage: UIImage? { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let qrContext = CIContext() + private let qrFilter = CIFilter.qrCodeGenerator() + + /// 重新加载邀请信息。 + func reload(api: any InviteServing) async { + loading = true + errorMessage = nil + defer { loading = false } + do { + let response = try await api.inviteInfo() + inviteCode = response.inviteCode + inviteUrl = response.inviteUrl + rules = response.description + qrImage = makeQrImage(from: response.inviteUrl) + } catch { + clear() + errorMessage = error.localizedDescription + } + } + + /// 复制邀请码。 + func copyInviteCode() { + UIPasteboard.general.string = inviteCode + } + + /// 复制邀请链接。 + func copyInviteUrl() { + UIPasteboard.general.string = inviteUrl + } + + /// 根据文本生成二维码图片。 + func makeQrImage(from text: String) -> UIImage? { + guard !text.isEmpty else { return nil } + qrFilter.setValue(Data(text.utf8), forKey: "inputMessage") + qrFilter.setValue("M", forKey: "inputCorrectionLevel") + guard let output = qrFilter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 12, y: 12)) + guard let cgImage = qrContext.createCGImage(scaled, from: scaled.extent) else { return nil } + return UIImage(cgImage: cgImage) + } + + private func clear() { + inviteCode = "" + inviteUrl = "" + rules = [] + qrImage = nil + } +} + +/// 邀请记录 ViewModel,负责邀请用户、奖励明细和钱包汇总分页。 +@MainActor +final class InviteRecordViewModel { + var onChange: (() -> Void)? + var tab: InviteRecordTab = .invite { didSet { onChange?() } } + private(set) var totalRewardText = "¥ 0.00" { didSet { onChange?() } } + private(set) var withdrawableText = "¥ 0.00" { didSet { onChange?() } } + private(set) var displayRows: [InviteDisplayRow] = [] { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var loadingMore = false { didSet { onChange?() } } + private(set) var hasMore = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private var inviteRows: [InviteDisplayRow] = [] + private var rewardRows: [InviteDisplayRow] = [] + private var invitePage = 1 + private var rewardPage = 1 + private let invitePageSize = 20 + private let rewardPageSize = 10 + + /// 重新加载或追加邀请记录数据。 + func reload(inviteAPI: any InviteServing, walletAPI: any WalletServing, refresh: Bool) async { + if refresh { + loading = true + } else { + guard hasMore else { return } + loadingMore = true + } + errorMessage = nil + defer { + loading = false + loadingMore = false + } + + do { + async let summary = walletAPI.walletSummary(type: 2) + if tab == .invite { + try await loadInviteRows(api: inviteAPI, refresh: refresh) + } else { + try await loadRewardRows(api: walletAPI, refresh: refresh) + } + let summaryValue = try await summary + totalRewardText = "¥ \(summaryValue.amountTotal)" + withdrawableText = "¥ \(summaryValue.amountWithdrawable)" + displayRows = tab == .invite ? inviteRows : rewardRows + } catch { + if refresh { + clear() + } + errorMessage = error.localizedDescription + } + } + + /// 切换邀请记录分段并刷新。 + func selectTab(_ newTab: InviteRecordTab, inviteAPI: any InviteServing, walletAPI: any WalletServing) async { + tab = newTab + await reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true) + } + + private func loadInviteRows(api: any InviteServing, refresh: Bool) async throws { + let users = try await api.inviteUserList(page: refresh ? 1 : invitePage, pageSize: invitePageSize) + let mapped = users.map { + InviteDisplayRow( + id: "invite_\($0.id)", + title: $0.realName.isEmpty ? "**\($0.phone.suffix(4))" : $0.realName, + subtitle: "", + amount: "", + time: $0.createdAt, + phone: $0.phone, + avatar: $0.avatar, + inviteLevel: $0.inviteLevel + ) + } + if refresh { + inviteRows = mapped + invitePage = 2 + } else { + inviteRows.append(contentsOf: mapped) + invitePage += 1 + } + hasMore = users.count >= invitePageSize + } + + private func loadRewardRows(api: any WalletServing, refresh: Bool) async throws { + let response = try await api.walletEarningDetail( + startDate: "2025-01-01", + endDate: Self.dayFormatter.string(from: Date()), + page: refresh ? 1 : rewardPage, + pageSize: rewardPageSize + ) + let mapped = response.list.flatMap { group in + group.items.map { + InviteDisplayRow( + id: "reward_\($0.id)", + title: $0.typeLabel.isEmpty ? "奖励记录" : $0.typeLabel, + subtitle: $0.withdrawLabel ?? "订单尾号:\($0.orderNumberSuffix.isEmpty ? "--" : $0.orderNumberSuffix)", + amount: $0.amount.isEmpty ? "--" : $0.amount, + time: $0.createdAt.isEmpty ? group.date : $0.createdAt, + phone: "", + avatar: "", + inviteLevel: 0 + ) + } + } + if refresh { + rewardRows = mapped + rewardPage = 2 + } else { + rewardRows.append(contentsOf: mapped) + rewardPage += 1 + } + hasMore = rewardRows.count < response.total && !mapped.isEmpty + } + + private func clear() { + totalRewardText = "¥ 0.00" + withdrawableText = "¥ 0.00" + displayRows = [] + inviteRows = [] + rewardRows = [] + invitePage = 1 + rewardPage = 1 + hasMore = false + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} diff --git a/suixinkan_ios/Features/Live/API/LiveAPI.swift b/suixinkan_ios/Features/Live/API/LiveAPI.swift new file mode 100644 index 0000000..862ad1d --- /dev/null +++ b/suixinkan_ios/Features/Live/API/LiveAPI.swift @@ -0,0 +1,159 @@ +// +// LiveAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 直播服务协议,定义直播管理和直播相册接口能力。 +@MainActor +protocol LiveServing { + func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse + func liveDetail(liveId: Int) async throws -> LiveEntity + func liveCreate(_ request: LiveCreateRequest) async throws + func liveStart(liveId: Int) async throws + func liveStop(liveId: Int) async throws + func liveFinish(liveId: Int) async throws + func liveSetPushMode(liveId: Int, mode: Int) async throws + func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse + func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws + func liveAlbumDeleteFolder(folderId: Int) async throws + func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem + func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws +} + +@MainActor +/// 直播 API,封装手动直播和直播相册网络请求。 +final class LiveAPI { + private let client: APIClient + + /// 初始化直播 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取直播列表。 + func liveList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> LiveListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/manual-live/list", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))) + ] + ) + ) + } + + /// 获取直播详情。 + func liveDetail(liveId: Int) async throws -> LiveEntity { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/manual-live/detail", + queryItems: [URLQueryItem(name: "live_id", value: String(liveId))] + ) + ) + } + + /// 创建直播。 + func liveCreate(_ request: LiveCreateRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/manual-live/create", body: request) + ) + } + + /// 开始直播。 + func liveStart(liveId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/manual-live/start", body: LiveControlRequest(liveId: liveId)) + ) + } + + /// 暂停直播。 + func liveStop(liveId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/manual-live/stop", body: LiveControlRequest(liveId: liveId)) + ) + } + + /// 结束直播。 + func liveFinish(liveId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/manual-live/finish", body: LiveControlRequest(liveId: liveId)) + ) + } + + /// 切换直播推流模式。 + func liveSetPushMode(liveId: Int, mode: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/manual-live/set-push-mode", body: LivePushModeRequest(liveId: liveId, manualPushMode: mode)) + ) + } + + /// 获取直播相册列表。 + func liveAlbumList( + scenicId: Int, + startTime: String? = nil, + endTime: String? = nil, + page: Int = 1, + pageSize: Int = 10 + ) async throws -> LiveAlbumFolderListResponse { + var queryItems = [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))) + ] + if let startTime = startTime?.liveTrimmed, !startTime.isEmpty { + queryItems.append(URLQueryItem(name: "start_time", value: startTime)) + } + if let endTime = endTime?.liveTrimmed, !endTime.isEmpty { + queryItems.append(URLQueryItem(name: "end_time", value: endTime)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/app/view-album/folders", queryItems: queryItems) + ) + } + + /// 创建直播相册。 + func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/view-album/create-folder", body: request) + ) + } + + /// 删除直播相册。 + func liveAlbumDeleteFolder(folderId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/view-album/delete-folder", body: LiveAlbumDeleteFolderRequest(folderId: folderId)) + ) + } + + /// 获取直播相册详情。 + func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/view-album/folder-detail", + queryItems: [URLQueryItem(name: "folder_id", value: String(folderId))] + ) + ) + } + + /// 删除直播相册内的素材。 + func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/view-album/delete-files", + body: LiveAlbumDeleteFilesRequest(folderId: folderId, fileIds: fileIds) + ) + ) + } +} + +extension LiveAPI: LiveServing {} diff --git a/suixinkan_ios/Features/Live/Live.md b/suixinkan_ios/Features/Live/Live.md new file mode 100644 index 0000000..6939f61 --- /dev/null +++ b/suixinkan_ios/Features/Live/Live.md @@ -0,0 +1,27 @@ +# 直播模块 + +## 模块职责 + +`Features/Live` 承接首页 `live_stream_management` 和 `live_album` 权限入口,负责手动直播管理和直播相册素材管理。旧 iOS 工程未接入真推流 SDK,本模块按旧 iOS 对齐,不新增腾讯 TRTC、RTMP 或其他推流 SDK 依赖。 + +## 代码结构 + +- `LiveAPI`:封装 `/api/app/manual-live/...` 和 `/api/app/view-album/...` 接口。 +- `LiveManagementViewModel`:管理直播列表分页、创建、开始/暂停、结束和详情失败清理。 +- `LivePlaybackViewModel`:管理直播详情和直播相册视频的系统播放器 URL、播放、暂停和释放状态。 +- `LivePushReadinessViewModel`:诊断直播推流地址、相机/麦克风权限、网络状态和当前推流 SDK 接入状态,不触发本机采集推流。 +- `LiveAlbumViewModel`:管理直播相册日期筛选、分页和删除相册。 +- `LiveAlbumCreateViewModel`:管理本地图片/视频上传到 OSS 后创建直播相册。 +- `LiveAlbumPreviewViewModel`:进入相册预览时加载相册详情,并支持删除单个素材。 + +## 业务边界 + +直播详情会从 `play_url`、`pull_url`、`hls_url`、`live_url`、`flv_url` 中筛选系统播放器可处理的 http/https 播放地址;`push_url` 是给 OBS 或第三方工具使用的外部推流地址,不作为播放地址使用。只有 RTMP 推流地址时,页面展示封面、提示和复制入口。 + +直播相册视频预览使用系统 `VideoPlayer` 播放可支持的视频地址;图片仍使用 `RemoteImage` 展示。视频地址不可播放时,页面保留打开和复制原始 URL 的操作。 + +推流专项按旧 iOS 对齐,只保留诊断层和适配协议,默认 `UnsupportedLivePushAdapter` 明确提示未接入真推流 SDK;不引入腾讯/TRTC、Agora、HaishinKit、Zego 或 Android `youfun_control` 的飞控/抓娃娃直播接口。直播详情的“开始/暂停/结束”和“推流模式”只调用 `manual-live` 业务接口,不启动本机摄像头、麦克风或后台推流。 + +后续若要接入真推流 SDK,可参考 Android `youfun_control` 中腾讯 TRTC 的实现路线,但需要单独确认后端房间参数、SDK 版本、真机权限、推流源和人工联调范围。 + +直播封面创建沿用旧 iOS 的 URL 输入。直播相册素材通过 `PhotosPicker` 选择后,使用 `OSSUploadService.uploadAliveAlbumFile` 上传到 `live_albums/yyyyMMdd/scenicId/...`,再把最终 URL 提交给创建相册接口。 diff --git a/suixinkan_ios/Features/Live/Models/LiveModels.swift b/suixinkan_ios/Features/Live/Models/LiveModels.swift new file mode 100644 index 0000000..867c15c --- /dev/null +++ b/suixinkan_ios/Features/Live/Models/LiveModels.swift @@ -0,0 +1,415 @@ +// +// LiveModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 直播列表响应实体。 +struct LiveListResponse: Decodable { + let items: [LiveEntity] + let total: Int + let page: Int + let pageSize: Int + + enum CodingKeys: String, CodingKey { + case items + case total + case page + case pageSize = "page_size" + } + + init(items: [LiveEntity] = [], total: Int = 0, page: Int = 1, pageSize: Int = 10) { + self.items = items + self.total = total + self.page = page + self.pageSize = pageSize + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + items = (try? container.decode([LiveEntity].self, forKey: .items)) ?? [] + total = try container.liveDecodeLossyInt(forKey: .total) ?? 0 + page = try container.liveDecodeLossyInt(forKey: .page) ?? 1 + pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10 + } +} + +/// 直播实体,表示一场手动直播。 +struct LiveEntity: Decodable, Identifiable, Equatable { + let id: Int + let title: String + let coverImg: String + let pushUrl: String + let playUrl: String + let pullUrl: String + let hlsUrl: String + let flvUrl: String + let liveUrl: String + let startTime: Int64 + let endTime: Int64 + let duration: Int64 + let status: Int + let statusLabel: String + let manualPushMode: Int + let manualPushState: Int + let viewsCount: Int + + enum CodingKeys: String, CodingKey { + case id + case title + case coverImg = "cover_img" + case pushUrl = "push_url" + case playUrl = "play_url" + case pullUrl = "pull_url" + case hlsUrl = "hls_url" + case flvUrl = "flv_url" + case liveUrl = "live_url" + case startTime = "start_time" + case endTime = "end_time" + case duration + case status + case statusLabel = "status_label" + case manualPushMode = "manaul_push_mode" + case manualPushState = "manaul_push_state" + case viewsCount = "views_count" + } + + init( + id: Int = 0, + title: String = "", + coverImg: String = "", + pushUrl: String = "", + playUrl: String = "", + pullUrl: String = "", + hlsUrl: String = "", + flvUrl: String = "", + liveUrl: String = "", + startTime: Int64 = 0, + endTime: Int64 = 0, + duration: Int64 = 0, + status: Int = 0, + statusLabel: String = "", + manualPushMode: Int = 1, + manualPushState: Int = 0, + viewsCount: Int = 0 + ) { + self.id = id + self.title = title + self.coverImg = coverImg + self.pushUrl = pushUrl + self.playUrl = playUrl + self.pullUrl = pullUrl + self.hlsUrl = hlsUrl + self.flvUrl = flvUrl + self.liveUrl = liveUrl + self.startTime = startTime + self.endTime = endTime + self.duration = duration + self.status = status + self.statusLabel = statusLabel + self.manualPushMode = manualPushMode + self.manualPushState = manualPushState + self.viewsCount = viewsCount + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 + title = try container.liveDecodeLossyString(forKey: .title) + coverImg = try container.liveDecodeLossyString(forKey: .coverImg) + pushUrl = try container.liveDecodeLossyString(forKey: .pushUrl) + playUrl = try container.liveDecodeLossyString(forKey: .playUrl) + pullUrl = try container.liveDecodeLossyString(forKey: .pullUrl) + hlsUrl = try container.liveDecodeLossyString(forKey: .hlsUrl) + flvUrl = try container.liveDecodeLossyString(forKey: .flvUrl) + liveUrl = try container.liveDecodeLossyString(forKey: .liveUrl) + startTime = Int64(try container.liveDecodeLossyInt(forKey: .startTime) ?? 0) + endTime = Int64(try container.liveDecodeLossyInt(forKey: .endTime) ?? 0) + duration = Int64(try container.liveDecodeLossyInt(forKey: .duration) ?? 0) + status = try container.liveDecodeLossyInt(forKey: .status) ?? 0 + statusLabel = try container.liveDecodeLossyString(forKey: .statusLabel) + manualPushMode = try container.liveDecodeLossyInt(forKey: .manualPushMode) ?? 1 + manualPushState = try container.liveDecodeLossyInt(forKey: .manualPushState) ?? 0 + viewsCount = try container.liveDecodeLossyInt(forKey: .viewsCount) ?? 0 + } + + var displayStatus: String { + statusLabel.liveNonEmpty ?? "状态\(status)" + } + + var playbackURLCandidates: [String] { + [playUrl, pullUrl, hlsUrl, liveUrl, flvUrl] + } +} + +/// 创建直播请求实体。 +struct LiveCreateRequest: Encodable, Equatable { + let scenicId: String + let title: String + let coverImg: String + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case title + case coverImg = "cover_img" + } +} + +/// 直播控制请求实体。 +struct LiveControlRequest: Encodable, Equatable { + let liveId: Int + + enum CodingKeys: String, CodingKey { + case liveId = "live_id" + } +} + +/// 直播推流模式切换请求实体。 +struct LivePushModeRequest: Encodable, Equatable { + let liveId: Int + let manualPushMode: Int + + enum CodingKeys: String, CodingKey { + case liveId = "live_id" + case manualPushMode = "manual_push_mode" + } +} + +/// 直播相册列表响应实体。 +struct LiveAlbumFolderListResponse: Decodable { + let items: [LiveAlbumFolderItem] + let page: Int + let pageSize: Int + let total: Int + let totalPages: Int + + enum CodingKeys: String, CodingKey { + case items + case page + case pageSize = "page_size" + case total + case totalPages = "total_pages" + } + + init(items: [LiveAlbumFolderItem] = [], page: Int = 1, pageSize: Int = 10, total: Int = 0, totalPages: Int = 0) { + self.items = items + self.page = page + self.pageSize = pageSize + self.total = total + self.totalPages = totalPages + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + items = (try? container.decode([LiveAlbumFolderItem].self, forKey: .items)) ?? [] + page = try container.liveDecodeLossyInt(forKey: .page) ?? 1 + pageSize = try container.liveDecodeLossyInt(forKey: .pageSize) ?? 10 + total = try container.liveDecodeLossyInt(forKey: .total) ?? 0 + totalPages = try container.liveDecodeLossyInt(forKey: .totalPages) ?? 0 + } +} + +/// 直播相册文件夹实体。 +struct LiveAlbumFolderItem: Decodable, Identifiable, Equatable { + let id: Int + let albumId: Int + let name: String + let creator: String + let items: [LiveAlbumFileItem] + + enum CodingKeys: String, CodingKey { + case id + case albumId = "album_id" + case name + case creator + case items + } + + init(id: Int = 0, albumId: Int = 0, name: String = "", creator: String = "", items: [LiveAlbumFileItem] = []) { + self.id = id + self.albumId = albumId + self.name = name + self.creator = creator + self.items = items + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 + albumId = try container.liveDecodeLossyInt(forKey: .albumId) ?? 0 + name = try container.liveDecodeLossyString(forKey: .name) + creator = try container.liveDecodeLossyString(forKey: .creator) + items = (try? container.decode([LiveAlbumFileItem].self, forKey: .items)) ?? [] + } +} + +/// 直播相册素材实体。 +struct LiveAlbumFileItem: Decodable, Identifiable, Hashable { + let id: Int + let url: String + let type: Int + let size: Int64 + let coverImg: String? + + enum CodingKeys: String, CodingKey { + case id + case url + case type + case size + case coverImg = "cover_img" + } + + init(id: Int = 0, url: String = "", type: Int = 1, size: Int64 = 0, coverImg: String? = nil) { + self.id = id + self.url = url + self.type = type + self.size = size + self.coverImg = coverImg + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.liveDecodeLossyInt(forKey: .id) ?? 0 + url = try container.liveDecodeLossyString(forKey: .url) + type = try container.liveDecodeLossyInt(forKey: .type) ?? 1 + size = Int64(try container.liveDecodeLossyInt(forKey: .size) ?? 0) + coverImg = try? container.decodeIfPresent(String.self, forKey: .coverImg) + } + + var isVideo: Bool { type == 2 } + + var previewURL: String { + if let cover = coverImg?.liveTrimmed, !cover.isEmpty { + return cover + } + return url + } +} + +/// 创建直播相册请求实体。 +struct LiveAlbumCreateFolderRequest: Encodable, Equatable { + let scenicId: String + let name: String + let items: [LiveAlbumCreateFileItem] + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case name + case items + } +} + +/// 创建直播相册素材请求实体。 +struct LiveAlbumCreateFileItem: Encodable, Equatable { + let url: String + let type: Int + let size: Int64 + let coverImg: String? + + enum CodingKeys: String, CodingKey { + case url + case type + case size + case coverImg = "cover_img" + } +} + +/// 删除直播相册请求实体。 +struct LiveAlbumDeleteFolderRequest: Encodable, Equatable { + let folderId: Int + + enum CodingKeys: String, CodingKey { + case folderId = "folder_id" + } +} + +/// 删除直播相册素材请求实体。 +struct LiveAlbumDeleteFilesRequest: Encodable, Equatable { + let folderId: Int + let fileIds: [Int] + + enum CodingKeys: String, CodingKey { + case folderId = "folder_id" + case fileIds = "file_ids" + } +} + +/// 本地待上传直播相册素材实体。 +struct LiveAlbumLocalUploadFile: Identifiable, Equatable { + let id: UUID + let data: Data + let fileName: String + let fileType: Int + let size: Int64 + var uploadedURL: String? + + init(id: UUID = UUID(), data: Data, fileName: String, fileType: Int, size: Int64? = nil, uploadedURL: String? = nil) { + self.id = id + self.data = data + self.fileName = fileName + self.fileType = fileType + self.size = size ?? Int64(data.count) + self.uploadedURL = uploadedURL + } + + var isVideo: Bool { fileType == 2 } +} + +private extension KeyedDecodingContainer { + func liveDecodeLossyString(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(Int64.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 ? "1" : "0" + } + return "" + } + + func liveDecodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int64.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(trimmed) { + return intValue + } + if let doubleValue = Double(trimmed) { + return Int(doubleValue) + } + } + return nil + } +} + +extension String { + /// 直播模块内部使用的去空白字符串。 + var liveTrimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// 直播模块内部使用的非空字符串兜底。 + var liveNonEmpty: String? { + let value = liveTrimmed + return value.isEmpty ? nil : value + } +} diff --git a/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift b/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift new file mode 100644 index 0000000..f2ae87f --- /dev/null +++ b/suixinkan_ios/Features/Live/ViewControllers/LiveViewControllers.swift @@ -0,0 +1,65 @@ +// +// LiveViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 直播管理页。 +final class LiveManagementViewController: ModuleTableViewController { + private let viewModel = LiveManagementViewModel() + + override func viewDidLoad() { + title = "直播管理" + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.items.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + cell.configure( + title: item.title, + subtitle: item.statusLabel, + detail: String(item.startTime) + ) + } + + override func reloadContent() async { + await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId) + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard indexPath.row >= viewModel.items.count - 2 else { return } + Task { await viewModel.loadMore(api: services.liveAPI, scenicId: services.currentScenicId) } + } +} + +extension LiveManagementViewModel: ViewModelBindable {} + +/// 直播相册页。 +final class LiveAlbumViewController: ModuleTableViewController { + private let viewModel = LiveAlbumViewModel() + + override func viewDidLoad() { + title = "直播相册" + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.folders.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let folder = viewModel.folders[indexPath.row] + cell.configure(title: folder.name, subtitle: folder.creator, detail: "\(folder.items.count) 张") + } + + override func reloadContent() async { + await viewModel.reload(api: services.liveAPI, scenicId: services.currentScenicId) + } +} + +extension LiveAlbumViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift b/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift new file mode 100644 index 0000000..c05f12b --- /dev/null +++ b/suixinkan_ios/Features/Live/ViewModels/LivePlaybackViewModel.swift @@ -0,0 +1,131 @@ +// +// LivePlaybackViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import AVFoundation +import Foundation + +/// 直播播放地址解析器,避免把 RTMP 推流地址误当作播放地址。 +enum LivePlaybackURLResolver { + static func playableURL(from live: LiveEntity) -> URL? { + playableURL(from: live.playbackURLCandidates) + } + + static func playableURL(from candidates: [String]) -> URL? { + for candidate in candidates { + if let url = playableURL(from: candidate) { + return url + } + } + return nil + } + + static func playableURL(from rawValue: String) -> URL? { + let value = rawValue.liveTrimmed + guard !value.isEmpty, let url = URL(string: value) else { return nil } + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil } + if url.pathExtension.lowercased() == "flv" { + return nil + } + return url + } +} + +/// 播放器状态。 +enum LivePlaybackState: Equatable { + case empty + case ready(URL) + case playing(URL) + case failed(String) +} + +@MainActor +/// 直播播放器 ViewModel,管理系统播放器的 URL、播放和释放状态。 +final class LivePlaybackViewModel { + var onChange: (() -> Void)? + var state: LivePlaybackState = .empty { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private(set) var player: AVPlayer? { didSet { onChange?() } } + + var playableURL: URL? { + switch state { + case .ready(let url), .playing(let url): + url + case .empty, .failed: + nil + } + } + + var isPlaying: Bool { + if case .playing = state { return true } + return false + } + + init(live: LiveEntity? = nil, urlString: String? = nil) { + if let live { + load(live: live) + } else if let urlString { + load(urlString: urlString) + } + } + + func load(live: LiveEntity) { + load(url: LivePlaybackURLResolver.playableURL(from: live)) + } + + func load(urlString: String) { + load(url: LivePlaybackURLResolver.playableURL(from: urlString)) + } + + func play() { + guard let url = playableURL else { return } + if player == nil { + player = AVPlayer(url: url) + } + player?.play() + state = .playing(url) + } + + func pause() { + guard let url = playableURL else { return } + player?.pause() + state = .ready(url) + } + + func reload() { + guard let url = playableURL else { return } + releasePlayer() + player = AVPlayer(url: url) + state = .ready(url) + } + + func release() { + releasePlayer() + if let url = playableURL { + state = .ready(url) + } else { + state = .empty + } + } + + private func load(url: URL?) { + releasePlayer() + guard let url else { + errorMessage = "暂无可播放地址" + state = .empty + return + } + errorMessage = nil + player = AVPlayer(url: url) + state = .ready(url) + } + + private func releasePlayer() { + player?.pause() + player = nil + } +} diff --git a/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift b/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift new file mode 100644 index 0000000..12441c0 --- /dev/null +++ b/suixinkan_ios/Features/Live/ViewModels/LivePushReadinessViewModel.swift @@ -0,0 +1,291 @@ +// +// LivePushReadinessViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import AVFoundation +import Foundation +import Network + +/// 直播推流权限状态。 +enum LivePushPermissionState: Equatable { + case unknown + case granted + case denied + + var displayText: String { + switch self { + case .unknown: "未检查" + case .granted: "已授权" + case .denied: "未授权" + } + } +} + +/// 直播推流网络状态。 +enum LivePushNetworkState: Equatable { + case unknown + case unavailable + case wifi + case cellular + case other + + var displayText: String { + switch self { + case .unknown: "检测中" + case .unavailable: "网络不可用" + case .wifi: "Wi-Fi" + case .cellular: "蜂窝网络" + case .other: "其他网络" + } + } +} + +/// 推流 SDK 适配协议。后续接入真实 RTMP/RTC SDK 时替换默认实现。 +protocol LivePushAdapter { + var name: String { get } + var isAvailable: Bool { get } + func prepare(pushURL: URL) async throws + func start() async throws + func stop() async throws + func dispose() async +} + +/// 权限提供者协议,隔离 AVFoundation 便于测试。 +protocol LivePermissionProviding { + func cameraPermission() async -> LivePushPermissionState + func microphonePermission() async -> LivePushPermissionState +} + +/// 网络监听协议,隔离 NWPathMonitor 便于测试。 +protocol LiveNetworkMonitoring: AnyObject { + var currentState: LivePushNetworkState { get } + func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) + func stop() +} + +enum LivePushReadinessError: LocalizedError, Equatable { + case missingPushURL + case invalidPushURL + case permissionDenied + case networkUnavailable + case sdkUnavailable + + var errorDescription: String? { + switch self { + case .missingPushURL: + "暂无推流地址" + case .invalidPushURL: + "推流地址格式无效" + case .permissionDenied: + "请先开启相机和麦克风权限" + case .networkUnavailable: + "当前网络不可用" + case .sdkUnavailable: + "当前版本未接入真推流 SDK" + } + } +} + +struct UnsupportedLivePushAdapter: LivePushAdapter { + let name = "未接入推流 SDK" + let isAvailable = false + + func prepare(pushURL: URL) async throws { + throw LivePushReadinessError.sdkUnavailable + } + + func start() async throws { + throw LivePushReadinessError.sdkUnavailable + } + + func stop() async throws {} + + func dispose() async {} +} + +struct SystemLivePermissionProvider: LivePermissionProviding { + func cameraPermission() async -> LivePushPermissionState { + await permission(for: .video) + } + + func microphonePermission() async -> LivePushPermissionState { + await permission(for: .audio) + } + + private func permission(for mediaType: AVMediaType) async -> LivePushPermissionState { + switch AVCaptureDevice.authorizationStatus(for: mediaType) { + case .authorized: + return .granted + case .notDetermined: + let granted = await AVCaptureDevice.requestAccess(for: mediaType) + return granted ? .granted : .denied + case .denied, .restricted: + return .denied + @unknown default: + return .denied + } + } +} + +final class SystemLiveNetworkMonitor: LiveNetworkMonitoring { + + var onChange: (() -> Void)? + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.suixinkan.live.network") + private(set) var currentState: LivePushNetworkState = .unknown + + func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) { + monitor.pathUpdateHandler = { [weak self] path in + let state = Self.state(from: path) + self?.currentState = state + onChange(state) + } + monitor.start(queue: queue) + } + + func stop() { + monitor.cancel() + } + + private static func state(from path: NWPath) -> LivePushNetworkState { + guard path.status == .satisfied else { return .unavailable } + if path.usesInterfaceType(.wifi) { return .wifi } + if path.usesInterfaceType(.cellular) { return .cellular } + return .other + } +} + +@MainActor +/// 推流准备 ViewModel,检查权限、网络和默认 SDK 可用性。 +final class LivePushReadinessViewModel { + var onChange: (() -> Void)? + var cameraPermission: LivePushPermissionState = .unknown { didSet { onChange?() } } + var microphonePermission: LivePushPermissionState = .unknown { didSet { onChange?() } } + var networkState: LivePushNetworkState = .unknown { didSet { onChange?() } } + var prepared = false { didSet { onChange?() } } + var running = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + let adapterName: String + let sdkStatusText: String + + private let permissionProvider: any LivePermissionProviding + private let networkMonitor: any LiveNetworkMonitoring + private let adapter: any LivePushAdapter + private var pushURL: URL? + + init( + permissionProvider: any LivePermissionProviding = SystemLivePermissionProvider(), + networkMonitor: any LiveNetworkMonitoring = SystemLiveNetworkMonitor(), + adapter: any LivePushAdapter = UnsupportedLivePushAdapter() + ) { + self.permissionProvider = permissionProvider + self.networkMonitor = networkMonitor + self.adapter = adapter + self.adapterName = adapter.name + self.sdkStatusText = adapter.isAvailable ? "已接入" : "未接入真推流 SDK" + self.networkState = networkMonitor.currentState + } + + func configure(pushURL rawValue: String) { + let value = rawValue.liveTrimmed + guard !value.isEmpty else { + pushURL = nil + errorMessage = LivePushReadinessError.missingPushURL.localizedDescription + return + } + guard let url = URL(string: value), url.scheme?.isEmpty == false else { + pushURL = nil + errorMessage = LivePushReadinessError.invalidPushURL.localizedDescription + return + } + pushURL = url + errorMessage = nil + } + + func startMonitoring() { + networkState = networkMonitor.currentState + networkMonitor.start { [weak self] state in + Task { @MainActor in + self?.networkState = state + } + } + } + + func stopMonitoring() { + networkMonitor.stop() + } + + func refreshPermissions() async { + async let camera = permissionProvider.cameraPermission() + async let microphone = permissionProvider.microphonePermission() + cameraPermission = await camera + microphonePermission = await microphone + } + + func runDiagnostics() throws { + do { + try validateReadiness() + } catch { + errorMessage = error.localizedDescription + throw error + } + guard adapter.isAvailable else { + errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription + throw LivePushReadinessError.sdkUnavailable + } + errorMessage = nil + } + + func prepare() async throws { + try validateReadiness() + guard let pushURL else { throw LivePushReadinessError.missingPushURL } + do { + try await adapter.prepare(pushURL: pushURL) + prepared = true + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + func startPush() async throws { + try validateReadiness() + guard adapter.isAvailable else { + errorMessage = LivePushReadinessError.sdkUnavailable.localizedDescription + throw LivePushReadinessError.sdkUnavailable + } + try await adapter.start() + running = true + prepared = true + errorMessage = nil + } + + func stopPush() async { + try? await adapter.stop() + running = false + } + + func dispose() async { + stopMonitoring() + await adapter.dispose() + running = false + prepared = false + } + + private func validateReadiness() throws { + guard pushURL != nil else { + throw LivePushReadinessError.missingPushURL + } + guard cameraPermission != .denied, microphonePermission != .denied else { + throw LivePushReadinessError.permissionDenied + } + guard networkState != .unavailable else { + throw LivePushReadinessError.networkUnavailable + } + } +} diff --git a/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift b/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift new file mode 100644 index 0000000..a5ec54b --- /dev/null +++ b/suixinkan_ios/Features/Live/ViewModels/LiveViewModels.swift @@ -0,0 +1,466 @@ +// +// LiveViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +@MainActor +/// 直播管理 ViewModel,负责直播列表、详情、创建和控制动作。 +final class LiveManagementViewModel { + var onChange: (() -> Void)? + var items: [LiveEntity] = [] { didSet { onChange?() } } + var detail: LiveEntity? { didSet { onChange?() } } + var loading = false { didSet { onChange?() } } + var loadingMore = false { didSet { onChange?() } } + var hasMore = false { didSet { onChange?() } } + var page = 1 { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 10 + private var total = 0 + + /// 进行中的直播数量。 + var liveRunningCount: Int { + items.filter { $0.status == 2 }.count + } + + /// 已结束的直播数量。 + var liveFinishedCount: Int { + items.filter { $0.status == 3 }.count + } + + /// 重新加载直播列表。 + func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async { + guard let scenicId else { + reset() + return + } + if showLoading { loading = true } + errorMessage = nil + defer { loading = false } + do { + try await loadPage(api: api, scenicId: scenicId, page: 1) + } catch { + clearListAndDetail() + errorMessage = error.localizedDescription + } + } + + /// 加载下一页直播列表。 + func loadMore(api: any LiveServing, scenicId: Int?) async { + guard let scenicId, hasMore, !loadingMore else { return } + loadingMore = true + defer { loadingMore = false } + do { + try await loadPage(api: api, scenicId: scenicId, page: page + 1) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 创建直播并刷新列表。 + func create(api: any LiveServing, scenicId: Int?, title: String, coverURL: String) async throws { + guard let scenicId else { + throw LiveValidationError.missingScenic + } + let normalizedTitle = title.liveTrimmed + let normalizedCover = coverURL.liveTrimmed + guard !normalizedTitle.isEmpty else { + throw LiveValidationError.emptyTitle + } + guard normalizedCover.liveIsHTTPURL else { + throw LiveValidationError.invalidCoverURL + } + try await api.liveCreate(LiveCreateRequest(scenicId: String(scenicId), title: normalizedTitle, coverImg: normalizedCover)) + await reload(api: api, scenicId: scenicId, showLoading: false) + } + + /// 加载直播详情,失败时清空旧详情。 + func loadDetail(api: any LiveServing, liveId: Int) async { + do { + detail = try await api.liveDetail(liveId: liveId) + } catch { + detail = nil + errorMessage = error.localizedDescription + } + } + + /// 开始或暂停直播,并刷新列表。 + func control(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws { + if item.status == 2 { + try await api.liveStop(liveId: item.id) + } else if item.status != 3 { + try await api.liveStart(liveId: item.id) + } + await reload(api: api, scenicId: scenicId, showLoading: false) + } + + /// 结束直播并刷新列表。 + func finish(api: any LiveServing, item: LiveEntity, scenicId: Int?) async throws { + try await api.liveFinish(liveId: item.id) + await reload(api: api, scenicId: scenicId, showLoading: false) + } + + private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws { + let response = try await api.liveList(scenicId: scenicId, page: page, pageSize: pageSize) + if page == 1 { + items = response.items + } else { + let incomingById = Dictionary(uniqueKeysWithValues: response.items.map { ($0.id, $0) }) + let kept = items.filter { incomingById[$0.id] == nil } + items = kept + response.items + } + self.page = page + total = response.total + hasMore = items.count < total + } + + private func reset() { + clearListAndDetail() + loading = false + loadingMore = false + errorMessage = nil + } + + private func clearListAndDetail() { + items = [] + detail = nil + page = 1 + total = 0 + hasMore = false + } +} + +@MainActor +/// 直播详情 ViewModel,负责直播详情页动作后的详情刷新。 +final class LiveDetailViewModel { + var onChange: (() -> Void)? + var detail: LiveEntity { didSet { onChange?() } } + var loading = false { didSet { onChange?() } } + var actionInFlight = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + init(detail: LiveEntity) { + self.detail = detail + } + + /// 刷新直播详情。 + func refresh(api: any LiveServing, showLoading: Bool = true) async { + if showLoading { loading = true } + defer { loading = false } + do { + detail = try await api.liveDetail(liveId: detail.id) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 开始或暂停当前直播。 + func control(api: any LiveServing) async throws { + guard detail.status != 3 else { return } + actionInFlight = true + defer { actionInFlight = false } + if detail.status == 2 { + try await api.liveStop(liveId: detail.id) + } else { + try await api.liveStart(liveId: detail.id) + } + await refresh(api: api, showLoading: false) + } + + /// 结束当前直播。 + func finish(api: any LiveServing) async throws { + guard detail.status != 3 else { return } + actionInFlight = true + defer { actionInFlight = false } + try await api.liveFinish(liveId: detail.id) + await refresh(api: api, showLoading: false) + } + + /// 切换推流模式。 + func setPushMode(api: any LiveServing, mode: Int) async throws { + guard detail.manualPushMode != mode else { return } + actionInFlight = true + defer { actionInFlight = false } + try await api.liveSetPushMode(liveId: detail.id, mode: mode) + await refresh(api: api, showLoading: false) + } +} + +@MainActor +/// 直播相册 ViewModel,负责相册列表、筛选、新建和删除。 +final class LiveAlbumViewModel { + var onChange: (() -> Void)? + var folders: [LiveAlbumFolderItem] = [] { didSet { onChange?() } } + var startDate: Date? { didSet { onChange?() } } + var endDate: Date? { didSet { onChange?() } } + var loading = false { didSet { onChange?() } } + var loadingMore = false { didSet { onChange?() } } + var hasMore = false { didSet { onChange?() } } + var page = 1 { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 10 + private var total = 0 + + /// 设置开始时间并校验时间顺序。 + func setStartDate(_ date: Date) throws { + if let endDate, Calendar.current.startOfDay(for: date) > Calendar.current.startOfDay(for: endDate) { + throw LiveValidationError.invalidDateRange + } + startDate = date + } + + /// 设置结束时间并校验时间顺序。 + func setEndDate(_ date: Date) throws { + if let startDate, Calendar.current.startOfDay(for: date) < Calendar.current.startOfDay(for: startDate) { + throw LiveValidationError.invalidDateRange + } + endDate = date + } + + /// 清除日期筛选。 + func clearDateFilters() { + startDate = nil + endDate = nil + } + + /// 重新加载直播相册列表。 + func reload(api: any LiveServing, scenicId: Int?, showLoading: Bool = true) async { + guard let scenicId else { + reset() + return + } + if showLoading { loading = true } + errorMessage = nil + defer { loading = false } + do { + try await loadPage(api: api, scenicId: scenicId, page: 1) + } catch { + clearFolders() + errorMessage = error.localizedDescription + } + } + + /// 加载下一页直播相册。 + func loadMore(api: any LiveServing, scenicId: Int?) async { + guard let scenicId, hasMore, !loadingMore else { return } + loadingMore = true + let nextPage = page + 1 + defer { loadingMore = false } + do { + try await loadPage(api: api, scenicId: scenicId, page: nextPage) + } catch { + page = max(1, nextPage - 1) + errorMessage = error.localizedDescription + } + } + + /// 删除直播相册并刷新。 + func deleteFolder(api: any LiveServing, folderId: Int, scenicId: Int?) async throws { + try await api.liveAlbumDeleteFolder(folderId: folderId) + await reload(api: api, scenicId: scenicId, showLoading: false) + } + + private func loadPage(api: any LiveServing, scenicId: Int, page: Int) async throws { + let response = try await api.liveAlbumList( + scenicId: scenicId, + startTime: startDate?.liveDayText, + endTime: endDate?.liveDayText, + page: page, + pageSize: pageSize + ) + if page == 1 { + folders = response.items + } else { + let incomingById = Set(response.items.map(\.id)) + folders = folders.filter { !incomingById.contains($0.id) } + response.items + } + self.page = page + total = response.total + hasMore = folders.count < total + } + + private func reset() { + clearFolders() + loading = false + loadingMore = false + errorMessage = nil + } + + private func clearFolders() { + folders = [] + page = 1 + total = 0 + hasMore = false + } +} + +@MainActor +/// 新建直播相册 ViewModel,负责本地素材上传和创建相册。 +final class LiveAlbumCreateViewModel { + var onChange: (() -> Void)? + var name = "" { didSet { onChange?() } } + var localFiles: [LiveAlbumLocalUploadFile] = [] { didSet { onChange?() } } + var submitting = false { didSet { onChange?() } } + var uploadProgress = 0 { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 添加本地待上传素材。 + func addLocalFiles(_ files: [LiveAlbumLocalUploadFile]) { + localFiles.append(contentsOf: files) + } + + /// 删除本地待上传素材。 + func removeLocalFile(id: UUID) { + localFiles.removeAll { $0.id == id } + } + + /// 上传本地素材并创建直播相册。 + func submit(scenicId: Int?, api: any LiveServing, uploader: any OSSUploadServing) async throws { + guard let scenicId else { throw LiveValidationError.missingScenic } + let normalizedName = name.liveTrimmed + guard !normalizedName.isEmpty else { throw LiveValidationError.emptyAlbumName } + guard !localFiles.isEmpty else { throw LiveValidationError.emptyAlbumFiles } + guard !submitting else { return } + + submitting = true + uploadProgress = 0 + defer { submitting = false } + + var uploadedItems: [LiveAlbumCreateFileItem] = [] + let count = max(localFiles.count, 1) + do { + for index in localFiles.indices { + let file = localFiles[index] + let url = try await uploader.uploadAliveAlbumFile( + data: file.data, + fileName: file.fileName, + fileType: file.fileType, + scenicId: scenicId + ) { progress in + Task { @MainActor in + let base = Double(index) / Double(count) + let step = Double(progress) / Double(count) + self.uploadProgress = min(99, Int((base + step / 100) * 100)) + } + } + localFiles[index].uploadedURL = url + uploadedItems.append( + LiveAlbumCreateFileItem(url: url, type: file.fileType, size: file.size, coverImg: nil) + ) + } + try await api.liveAlbumCreateFolder( + LiveAlbumCreateFolderRequest(scenicId: String(scenicId), name: normalizedName, items: uploadedItems) + ) + name = "" + localFiles = [] + uploadProgress = 100 + } catch { + errorMessage = error.localizedDescription + throw error + } + } +} + +@MainActor +/// 直播相册预览 ViewModel,负责加载相册详情和删除当前素材。 +final class LiveAlbumPreviewViewModel { + var onChange: (() -> Void)? + let folderId: Int + var folder: LiveAlbumFolderItem? { didSet { onChange?() } } + var files: [LiveAlbumFileItem] = [] { didSet { onChange?() } } + var currentIndex: Int { didSet { onChange?() } } + var loading = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + init(folderId: Int, startIndex: Int = 0, summary: LiveAlbumFolderItem? = nil) { + self.folderId = folderId + currentIndex = max(startIndex, 0) + folder = summary + files = summary?.items ?? [] + } + + /// 加载相册详情。 + func load(api: any LiveServing) async { + loading = true + defer { loading = false } + do { + let detail = try await api.liveAlbumFolderDetail(folderId: folderId) + folder = detail + files = detail.items + if currentIndex >= files.count { + currentIndex = max(files.count - 1, 0) + } + } catch { + files = [] + errorMessage = error.localizedDescription + } + } + + /// 删除当前预览素材并刷新详情。 + func deleteCurrentFile(api: any LiveServing) async throws { + guard files.indices.contains(currentIndex) else { return } + let file = files[currentIndex] + try await api.liveAlbumDeleteFiles(folderId: folderId, fileIds: [file.id]) + await load(api: api) + } +} + +/// 直播模块校验错误。 +enum LiveValidationError: LocalizedError, Equatable { + case missingScenic + case emptyTitle + case invalidCoverURL + case invalidDateRange + case emptyAlbumName + case emptyAlbumFiles + + var errorDescription: String? { + switch self { + case .missingScenic: + "当前账号缺少景区信息" + case .emptyTitle: + "请输入直播标题" + case .invalidCoverURL: + "封面图地址需以 http:// 或 https:// 开头" + case .invalidDateRange: + "开始时间不能大于结束时间" + case .emptyAlbumName: + "请输入相册名称" + case .emptyAlbumFiles: + "请上传素材" + } + } +} + +extension Date { + /// 直播接口使用的日期格式。 + var liveDayText: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: self) + } +} + +private extension String { + var liveIsHTTPURL: Bool { + let lower = liveTrimmed.lowercased() + return lower.hasPrefix("http://") || lower.hasPrefix("https://") + } +} + +extension Int64 { + /// 直播时长展示文案。 + var liveDurationText: String { + let totalSeconds = Swift.max(Int(self), 0) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" + } +} diff --git a/suixinkan_ios/Features/LocationReport/API/LocationReportAPI.swift b/suixinkan_ios/Features/LocationReport/API/LocationReportAPI.swift new file mode 100644 index 0000000..214cf2c --- /dev/null +++ b/suixinkan_ios/Features/LocationReport/API/LocationReportAPI.swift @@ -0,0 +1,80 @@ +// +// LocationReportAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 位置上报服务协议,抽象上报和历史接口以便 ViewModel 测试替换。 +@MainActor +protocol LocationReportServing { + /// 提交一次位置上报。 + func reportLocation(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int) async throws -> LocationReportSubmitResponse + + /// 获取位置上报历史列表。 + func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload +} + +/// 位置上报 API,负责封装旧工程定位上报相关接口。 +@MainActor +final class LocationReportAPI: LocationReportServing { + private let client: APIClient + + /// 初始化位置上报 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 提交一次位置上报。 + func reportLocation( + staffId: Int, + latitude: Double, + longitude: Double, + address: String, + type: LocationReportType, + scenicId: Int + ) async throws -> LocationReportSubmitResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/loacation/report", + queryItems: [ + URLQueryItem(name: "staff_id", value: "\(staffId)"), + URLQueryItem(name: "latitude", value: "\(latitude)"), + URLQueryItem(name: "longitude", value: "\(longitude)"), + URLQueryItem(name: "address", value: address), + URLQueryItem(name: "type", value: "\(type.rawValue)"), + URLQueryItem(name: "scenic_id", value: "\(scenicId)") + ] + ) + ) + } + + /// 获取位置上报历史列表。 + func locationReportList( + staffId: Int, + page: Int = 1, + pageSize: Int = 20, + type: LocationReportType = .all, + startDate: String? = nil, + endDate: String? = nil + ) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "staff_id", value: "\(staffId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"), + URLQueryItem(name: "type", value: "\(max(type.rawValue, 0))") + ] + if let startDate, !startDate.isEmpty { + query.append(URLQueryItem(name: "start_date", value: startDate)) + } + if let endDate, !endDate.isEmpty { + query.append(URLQueryItem(name: "end_date", value: endDate)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/loacation/list", queryItems: query) + ) + } +} diff --git a/suixinkan_ios/Features/LocationReport/LocationReport.md b/suixinkan_ios/Features/LocationReport/LocationReport.md new file mode 100644 index 0000000..e7df61a --- /dev/null +++ b/suixinkan_ios/Features/LocationReport/LocationReport.md @@ -0,0 +1,39 @@ +# LocationReport 模块业务逻辑 + +## 模块职责 + +LocationReport 模块负责首页 `location_report` 和 `location_report_history` 入口。 + +本模块包含当前位置获取、标记点、在线状态、立即上报、提醒设置、历史记录、类型筛选、日期筛选和分页。上报状态只保存在页面 ViewModel 内,不进入全局 App 状态。 + +## 数据来源 + +- 当前景区 ID 从 `AccountContext.currentScenic` 读取。 +- 上报人员 ID 从 `AccountSnapshotStore.load()?.businessUserId` 读取,避免把业务账号 ID 复制进页面全局状态。 +- 提交接口使用旧工程拼写 `/api/yf-handset-app/photog/loacation/report`。 +- 历史接口使用 `/api/yf-handset-app/photog/loacation/list`。 + +## 上报流程 + +`LocationReportViewModel` 管理当前位置、标记点、在线状态、提醒分钟数和页面内倒计时。 + +1. 页面进入时请求一次前台定位。 +2. 用户可以立即上报当前位置,也可以把当前位置设为标记点后上报。 +3. 切换到在线状态时提交一次在线状态上报。 +4. 上报成功后按服务端 `expired` 重置倒计时;服务端未返回时兜底为 2 小时。 + +## 历史流程 + +`LocationReportHistoryViewModel` 管理类型筛选、日期筛选、分页和错误状态。 + +历史记录支持 `全部`、`立即上报`、`标记点`、`在线状态` 四类筛选。分页失败只保留错误提示,不影响登录态和其他模块。 + +## 定位边界 + +当前只做前台手动定位和页面内倒计时。本轮不做后台持续定位、后台定时上报、离线持久化队列或推送提醒。 + +定位结果、在线状态、提醒倒计时和提交表单均不落盘。 + +## 测试要求 + +新增位置上报逻辑时,需要同步补充 API、ViewModel 和路由测试。测试失败时先修复问题,再继续迁移后续功能。 diff --git a/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift b/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift new file mode 100644 index 0000000..f22868a --- /dev/null +++ b/suixinkan_ios/Features/LocationReport/Models/LocationReportModels.swift @@ -0,0 +1,127 @@ +// +// LocationReportModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 位置上报类型实体,表示立即上报、标记点上报和在线状态上报。 +enum LocationReportType: Int, CaseIterable, Identifiable { + case all = 0 + case immediate = 1 + case marked = 2 + case onlineStatus = 3 + + var id: Int { rawValue } + + /// 上报类型展示标题。 + var title: String { + switch self { + case .all: "全部" + case .immediate: "立即上报" + case .marked: "标记点" + case .onlineStatus: "在线状态" + } + } +} + +/// 位置坐标实体,表示定位或手动选点得到的经纬度。 +struct LocationCoordinate: Equatable { + var latitude: Double + var longitude: Double +} + +/// 位置上报提交响应实体,表示服务端返回的上报人员、过期秒数和状态。 +struct LocationReportSubmitResponse: Decodable, Equatable { + let staffId: String + let expired: Int + let status: Int + + enum CodingKeys: String, CodingKey { + case staffId = "staff_id" + case expired + case status + } + + /// 创建位置上报提交响应,主要用于测试替身返回。 + init(staffId: String, expired: Int, status: Int) { + self.staffId = staffId + self.expired = expired + self.status = status + } + + /// 宽松解码提交响应字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + staffId = try container.decodeLossyString(forKey: .staffId) + expired = try container.decodeLossyInt(forKey: .expired) ?? 0 + status = try container.decodeLossyInt(forKey: .status) ?? 0 + } +} + +/// 位置上报历史项实体,表示一次历史上报记录。 +struct LocationReportHistoryItem: Decodable, Equatable, Identifiable { + let id: Int + let staffId: Int + let type: Int + let latitude: String + let longitude: String + let address: String + let ip: String + let remark: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case staffId = "staff_id" + case type + case latitude + case longitude + case address + case ip + case remark + case createdAt = "created_at" + } + + /// 宽松解码历史项字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + staffId = try container.decodeLossyInt(forKey: .staffId) ?? 0 + type = try container.decodeLossyInt(forKey: .type) ?? 0 + latitude = try container.decodeLossyString(forKey: .latitude) + longitude = try container.decodeLossyString(forKey: .longitude) + address = try container.decodeLossyString(forKey: .address) + ip = try container.decodeLossyString(forKey: .ip) + remark = try container.decodeLossyString(forKey: .remark) + createdAt = try container.decodeLossyString(forKey: .createdAt) + } + + /// 上报类型展示文案。 + var typeTitle: String { + LocationReportType(rawValue: type)?.title ?? "未知" + } +} + +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 ? "1" : "0" } + return "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} diff --git a/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift b/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift new file mode 100644 index 0000000..999ddd0 --- /dev/null +++ b/suixinkan_ios/Features/LocationReport/ViewControllers/LocationReportViewControllers.swift @@ -0,0 +1,119 @@ +// +// LocationReportViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 位置上报页。 +final class LocationReportViewController: ModuleTableViewController { + private let viewModel = LocationReportViewModel() + + override func viewDidLoad() { + title = "位置上报" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "上报", + style: .done, + target: self, + action: #selector(submitReport) + ) + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? 3 : 1 + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? "状态" : "操作" + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, + for: indexPath + ) as! TitleSubtitleTableViewCell + if indexPath.section == 0 { + switch indexPath.row { + case 0: + cell.configure(title: "在线状态", subtitle: viewModel.isOnline ? "在线" : "离线") + case 1: + cell.configure(title: "当前地址", subtitle: viewModel.currentAddress) + case 2: + cell.configure(title: "倒计时", subtitle: viewModel.countdownText) + default: + break + } + } else { + cell.configure(title: "切换在线状态", subtitle: viewModel.isOnline ? "点击离线" : "点击上线") + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if indexPath.section == 1 { + Task { + _ = await viewModel.setOnline( + !viewModel.isOnline, + staffId: services.staffId, + scenicId: services.currentScenicId, + api: services.locationReportAPI + ) + } + } + } + + override func reloadContent() async { + viewModel.applyCurrentLocation(latitude: 39.9, longitude: 116.4, address: "定位待接入") + } + + @objc private func submitReport() { + Task { + _ = await viewModel.submit( + type: .immediate, + staffId: services.staffId, + scenicId: services.currentScenicId, + api: services.locationReportAPI + ) + } + } +} + +extension LocationReportViewModel: ViewModelBindable {} + +/// 位置上报历史页。 +final class LocationReportHistoryViewController: ModuleTableViewController { + private let viewModel = LocationReportHistoryViewModel() + + override func viewDidLoad() { + title = "上报历史" + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { + viewModel.items.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let record = viewModel.items[indexPath.row] + cell.configure(title: record.typeTitle, subtitle: record.address, detail: record.createdAt) + } + + override func reloadContent() async { + await viewModel.reload(staffId: services.staffId, api: services.locationReportAPI) + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard indexPath.row >= viewModel.items.count - 2 else { return } + Task { await viewModel.loadMore(staffId: services.staffId, api: services.locationReportAPI) } + } +} + +extension LocationReportHistoryViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/LocationReport/ViewModels/LocationReportViewModels.swift b/suixinkan_ios/Features/LocationReport/ViewModels/LocationReportViewModels.swift new file mode 100644 index 0000000..95364eb --- /dev/null +++ b/suixinkan_ios/Features/LocationReport/ViewModels/LocationReportViewModels.swift @@ -0,0 +1,232 @@ +// +// LocationReportViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 位置上报 ViewModel,负责页面内在线状态、坐标、倒计时和提交动作。 +@MainActor +final class LocationReportViewModel { + var onChange: (() -> Void)? + var isOnline = false { didSet { onChange?() } } + var reminderMinutes = 30 { didSet { onChange?() } } + var secondsUntilReport = 0 { didSet { onChange?() } } + var currentCoordinate: LocationCoordinate? { didSet { onChange?() } } + var markedCoordinate: LocationCoordinate? { didSet { onChange?() } } + var currentAddress = "" { didSet { onChange?() } } + var markedAddress = "" { didSet { onChange?() } } + var lastReportText = "" { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + var isSubmitting = false { didSet { onChange?() } } + + /// 倒计时展示文案。 + var countdownText: String { + guard secondsUntilReport > 0 else { return "可立即上报" } + let hours = secondsUntilReport / 3600 + let minutes = (secondsUntilReport % 3600) / 60 + return "\(hours)小时\(minutes)分钟后可再次提醒" + } + + /// 设置当前位置。 + func applyCurrentLocation(latitude: Double, longitude: Double, address: String) { + currentCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude) + currentAddress = address + } + + /// 设置标记点位置。 + func applyMarkedLocation(latitude: Double, longitude: Double, address: String) { + markedCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude) + markedAddress = address + } + + /// 切换在线状态;切到在线时会提交一次状态上报。 + func setOnline( + _ value: Bool, + staffId: Int?, + scenicId: Int?, + api: any LocationReportServing + ) async -> Bool { + isOnline = value + guard value else { return true } + return await submit(type: .onlineStatus, staffId: staffId, scenicId: scenicId, api: api) + } + + /// 提交指定类型的位置上报。 + func submit( + type: LocationReportType, + staffId: Int?, + scenicId: Int?, + api: any LocationReportServing + ) async -> Bool { + guard !isSubmitting else { return false } + guard let staffId, staffId > 0 else { + errorMessage = "缺少上报人员" + return false + } + guard let scenicId, scenicId > 0 else { + errorMessage = "缺少当前景区" + return false + } + + let source = reportSource(for: type) + guard let coordinate = source.coordinate else { + errorMessage = "请先获取或选择位置" + return false + } + let address = source.address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !address.isEmpty else { + errorMessage = "请填写位置地址" + return false + } + + isSubmitting = true + errorMessage = nil + defer { isSubmitting = false } + + do { + let response = try await api.reportLocation( + staffId: staffId, + latitude: coordinate.latitude, + longitude: coordinate.longitude, + address: address, + type: type, + scenicId: scenicId + ) + secondsUntilReport = response.expired > 0 ? response.expired : 7200 + lastReportText = LocationReportViewModel.displayFormatter.string(from: Date()) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 页面内倒计时每秒递减。 + func tick() { + guard secondsUntilReport > 0 else { return } + secondsUntilReport -= 1 + } + + /// 根据上报类型选择当前坐标或标记点坐标。 + private func reportSource(for type: LocationReportType) -> (coordinate: LocationCoordinate?, address: String) { + switch type { + case .marked: + (markedCoordinate ?? currentCoordinate, markedAddress.isEmpty ? currentAddress : markedAddress) + case .all, .immediate, .onlineStatus: + (currentCoordinate, currentAddress) + } + } + + private static let displayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() +} + +/// 位置上报历史 ViewModel,负责筛选、日期和分页加载。 +@MainActor +final class LocationReportHistoryViewModel { + var onChange: (() -> Void)? + var selectedType: LocationReportType = .all { didSet { onChange?() } } + var startDate: Date? { didSet { onChange?() } } + var endDate: Date? { didSet { onChange?() } } + var items: [LocationReportHistoryItem] = [] { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var total = 0 { didSet { onChange?() } } + + private var page = 1 + private let pageSize = 20 + + /// 是否还有下一页历史记录。 + var hasMore: Bool { + items.count < total + } + + /// 重新加载历史记录。 + func reload(staffId: Int?, api: any LocationReportServing) async { + guard let staffId, staffId > 0 else { + reset() + errorMessage = "缺少上报人员" + return + } + page = 1 + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let payload = try await api.locationReportList( + staffId: staffId, + page: page, + pageSize: pageSize, + type: selectedType, + startDate: dateString(startDate), + endDate: dateString(endDate) + ) + items = payload.list + total = payload.total + } catch { + items = [] + total = 0 + errorMessage = error.localizedDescription + } + } + + /// 加载下一页历史记录。 + func loadMore(staffId: Int?, api: any LocationReportServing) async { + guard let staffId, staffId > 0, hasMore, !isLoading, !isLoadingMore else { return } + isLoadingMore = true + let nextPage = page + 1 + defer { isLoadingMore = false } + + do { + let payload = try await api.locationReportList( + staffId: staffId, + page: nextPage, + pageSize: pageSize, + type: selectedType, + startDate: dateString(startDate), + endDate: dateString(endDate) + ) + page = nextPage + items.append(contentsOf: payload.list) + total = payload.total + } catch { + errorMessage = error.localizedDescription + } + } + + /// 切换历史类型筛选并刷新。 + func selectType(_ type: LocationReportType, staffId: Int?, api: any LocationReportServing) async { + guard selectedType != type else { return } + selectedType = type + await reload(staffId: staffId, api: api) + } + + /// 清空历史状态。 + func reset() { + items = [] + total = 0 + page = 1 + isLoading = false + isLoadingMore = false + } + + /// 将日期转为接口需要的 yyyy-MM-dd 字符串。 + private func dateString(_ date: Date?) -> String? { + guard let date else { return nil } + return Self.dateFormatter.string(from: date) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} diff --git a/suixinkan_ios/Features/Main/Main.md b/suixinkan_ios/Features/Main/Main.md new file mode 100644 index 0000000..caccf7b --- /dev/null +++ b/suixinkan_ios/Features/Main/Main.md @@ -0,0 +1,59 @@ +# Main 模块业务逻辑 + +## 模块职责 + +Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。 + +主界面由 `MainTabsView` 承载: +- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`。 +- 默认使用自定义 TabBar;如需切回系统实现,将配置改为 `.system`。 +- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。 +- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。 +- 每个 Tab 的导航路径由 `AppRouter` 单独保存。 + +## Tab 结构 + +当前 Tab 来自 `AppTab`: +- `home`:首页 +- `orders`:订单 +- `statistics`:数据 +- `profile`:我的 + +`HomeRootView` 已接入真实的 `HomeView`,`OrdersRootView` 已接入真实的 `OrdersView`,`StatisticsRootView` 已接入真实的 `StatisticsView`,`ProfileRootView` 已接入真实的 `ProfileView`。 + +## 导航流程 + +1. `MainTabsView` 从 Environment 读取 `AppRouter`。 +2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView` 或 `SystemMainTabsView`。 +3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态。 +4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`。 +5. 路径绑定来自 `appRouter.binding(for:)`。 +6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。 +7. `navigationDestination` 根据 `AppRoute` 展示详情页。 +8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。 + +## 自定义 TabBar + +自定义 TabBar 由 `CustomMainTabsView` 和 `CustomMainTabBar` 组成: +- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。 +- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。 +- `CustomMainTabBar` 只负责展示 UI,不直接读取账号、订单 API 或业务上下文。 +- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。 +- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`。 +- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。 +- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar,也不会保留底部占位高度。 +- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。 + +系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。 + +## 后续迁移规则 + +迁移新页面时: +- 优先替换对应 Tab 的 RootView。 +- 保持每个 Tab 自己的 `NavigationStack`。 +- 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。 +- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。 +- Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。 +- 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。 +- 不要把业务状态塞进自定义 TabBar 组件;TabBar 只接收绑定、文案和动作回调。 +- 真实业务页面接入后,应同步补充该模块文档和单元测试。 diff --git a/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift b/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift new file mode 100644 index 0000000..d8a732c --- /dev/null +++ b/suixinkan_ios/Features/Main/ViewControllers/MainTabBarController.swift @@ -0,0 +1,351 @@ +// +// MainTabBarController.swift +// suixinkan +// + +import SnapKit +import UIKit + +@MainActor +/// 主 Tab 容器,使用自定义底部栏展示四个一级入口和中间扫码按钮。 +final class MainTabBarController: UIViewController { + + private let services: AppServices + private let badgeViewModel = MainTabBadgeViewModel() + private let contentContainer = UIView() + private let customTabBar = CustomMainTabBarView() + + private var tabNavigationControllers: [AppTab: TabNavigationController] = [:] + private var loadedTabs: Set = [.home] + + init(services: AppServices) { + self.services = services + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .white + configureLayout() + bindRouter() + bindBadgeViewModel() + bindAccountContext() + switchToTab(services.appRouter.selectedTab, animated: false) + Task { await refreshOrderBadge() } + #if DEBUG + Task { await AppUITestRouteDriver.applyIfNeeded(services: services) } + #endif + } + + /// 控制自定义 TabBar 显隐,push 子页面时隐藏。 + func setCustomTabBarHidden(_ hidden: Bool, animated: Bool) { + let updates = { + self.customTabBar.alpha = hidden ? 0 : 1 + self.customTabBar.isUserInteractionEnabled = !hidden + } + + guard animated else { + updates() + return + } + + UIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseInOut], animations: updates) + } + + private func configureLayout() { + view.addSubview(contentContainer) + view.addSubview(customTabBar) + + customTabBar.onTabSelected = { [weak self] tab in + self?.services.appRouter.select(tab) + } + customTabBar.onScanTapped = { [weak self] in + self?.presentScanner() + } + + contentContainer.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(customTabBar.snp.top) + } + + customTabBar.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide) + make.height.equalTo(72) + } + } + + private func bindRouter() { + services.appRouter.onChange = { [weak self] in + self?.handleRouterChange() + } + } + + private func bindBadgeViewModel() { + badgeViewModel.onChange = { [weak self] in + self?.updateOrderBadge() + } + } + + private func bindAccountContext() { + services.accountContext.onChange = { [weak self] in + Task { await self?.refreshOrderBadge() } + } + } + + private func handleRouterChange() { + switchToTab(services.appRouter.selectedTab, animated: false) + if services.appRouter.selectedTab == .orders { + Task { await refreshOrderBadge() } + } + } + + private func switchToTab(_ tab: AppTab, animated: Bool) { + loadedTabs.insert(tab) + customTabBar.selectedTab = tab + + let navigationController = navigationController(for: tab) + for child in children where child !== navigationController { + child.willMove(toParent: nil) + child.view.removeFromSuperview() + child.removeFromParent() + } + + addChild(navigationController) + contentContainer.addSubview(navigationController.view) + navigationController.view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + navigationController.didMove(toParent: self) + + let isRoot = navigationController.viewControllers.count <= 1 + setCustomTabBarHidden(!isRoot, animated: animated) + } + + private func navigationController(for tab: AppTab) -> TabNavigationController { + if let existing = tabNavigationControllers[tab] { + return existing + } + + let navigationController = TabNavigationController(tab: tab, services: services) + navigationController.tabBarHost = self + tabNavigationControllers[tab] = navigationController + return navigationController + } + + private func updateOrderBadge() { + guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else { + customTabBar.orderBadgeText = nil + return + } + customTabBar.orderBadgeText = "\(count)" + } + + private func refreshOrderBadge() async { + await badgeViewModel.refreshPendingWriteOffCount( + api: services.ordersAPI, + scenicId: services.accountContext.currentScenic?.id, + storeId: services.accountContext.currentStore?.id + ) + } + + private func presentScanner() { + let scanner = OrderCodeScannerViewController() + scanner.onScanResult = { [weak self] result in + guard let self else { return } + switch result { + case .success(let rawCode): + dismiss(animated: true) { + self.services.appRouter.routeToOrderVerification(scannedCode: rawCode) + } + case .failure(let error): + showToast(error.localizedDescription) + } + } + let navigation = UINavigationController(rootViewController: scanner) + navigation.modalPresentationStyle = .fullScreen + present(navigation, animated: true) + } +} + +/// 自定义主 TabBar,展示四个一级入口和中间扫码核销按钮。 +private final class CustomMainTabBarView: UIView { + + var selectedTab: AppTab = .home { + didSet { refreshSelection() } + } + + var orderBadgeText: String? { + didSet { ordersBadgeLabel.text = orderBadgeText; ordersBadgeLabel.isHidden = orderBadgeText == nil } + } + + var onTabSelected: ((AppTab) -> Void)? + var onScanTapped: (() -> Void)? + + private struct TabItem { + let tab: AppTab + let title: String + let selectedImage: String + let unselectedImage: String + } + + private let items: [TabItem] = [ + .init(tab: .home, title: "首页", selectedImage: "TabHomeSelected", unselectedImage: "TabHomeUnselected"), + .init(tab: .orders, title: "订单", selectedImage: "TabOrderSelected", unselectedImage: "TabOrderUnselected"), + .init(tab: .statistics, title: "数据", selectedImage: "TabDataSelected", unselectedImage: "TabDataUnselected"), + .init(tab: .profile, title: "我的", selectedImage: "TabProfileSelected", unselectedImage: "TabProfileUnselected") + ] + + private var tabButtons: [AppTab: UIButton] = [:] + private var tabTitleLabels: [AppTab: UILabel] = [:] + private let ordersBadgeLabel = UILabel() + private let topDivider = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + configureViews() + refreshSelection() + } + + required init?(coder: NSCoder) { + nil + } + + private func configureViews() { + topDivider.backgroundColor = UIColor.black.withAlphaComponent(0.04) + addSubview(topDivider) + topDivider.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(0.5) + } + + let leftStack = UIStackView() + leftStack.axis = .horizontal + leftStack.distribution = .fillEqually + leftStack.spacing = 0 + + let rightStack = UIStackView() + rightStack.axis = .horizontal + rightStack.distribution = .fillEqually + rightStack.spacing = 0 + + for (index, item) in items.enumerated() { + let buttonContainer = makeTabButton(for: item) + if index < 2 { + leftStack.addArrangedSubview(buttonContainer) + } else { + rightStack.addArrangedSubview(buttonContainer) + } + } + + let scanButton = UIButton(type: .system) + scanButton.backgroundColor = AppDesign.primary + scanButton.layer.cornerRadius = 29 + scanButton.tintColor = .white + scanButton.setImage( + UIImage(systemName: "qrcode.viewfinder")?.withConfiguration( + UIImage.SymbolConfiguration(pointSize: 30, weight: .bold) + ), + for: .normal + ) + scanButton.accessibilityLabel = "扫码核销" + scanButton.accessibilityIdentifier = "main.scan" + scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside) + + let row = UIStackView(arrangedSubviews: [leftStack, scanButton, rightStack]) + row.axis = .horizontal + row.alignment = .center + row.spacing = 0 + addSubview(row) + + scanButton.snp.makeConstraints { make in + make.width.equalTo(74) + make.height.equalTo(64) + } + + row.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(22) + make.top.bottom.equalToSuperview() + } + + leftStack.snp.makeConstraints { make in + make.width.equalTo(rightStack) + } + } + + private func makeTabButton(for item: TabItem) -> UIView { + let container = UIView() + + let button = UIButton(type: .custom) + button.accessibilityLabel = item.title + button.accessibilityIdentifier = "main.tab.\(item.tab.rawValue)" + button.tag = items.firstIndex(where: { $0.tab == item.tab }) ?? 0 + button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) + tabButtons[item.tab] = button + + let titleLabel = UILabel() + titleLabel.text = item.title + titleLabel.font = .systemFont(ofSize: 12) + titleLabel.textAlignment = .center + tabTitleLabels[item.tab] = titleLabel + + container.addSubview(button) + container.addSubview(titleLabel) + + button.snp.makeConstraints { make in + make.top.equalToSuperview().offset(8) + make.centerX.equalToSuperview() + make.width.height.equalTo(24) + } + + titleLabel.snp.makeConstraints { make in + make.top.equalTo(button.snp.bottom).offset(3) + make.centerX.equalToSuperview() + make.bottom.lessThanOrEqualToSuperview() + } + + if item.tab == .orders { + ordersBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold) + ordersBadgeLabel.textColor = .white + ordersBadgeLabel.backgroundColor = UIColor(hex: 0xEF4444) + ordersBadgeLabel.textAlignment = .center + ordersBadgeLabel.layer.cornerRadius = 7.5 + ordersBadgeLabel.clipsToBounds = true + ordersBadgeLabel.isHidden = true + container.addSubview(ordersBadgeLabel) + ordersBadgeLabel.snp.makeConstraints { make in + make.top.equalTo(button).offset(-4) + make.leading.equalTo(button.snp.trailing).offset(-6) + make.height.equalTo(15) + make.width.greaterThanOrEqualTo(15) + } + } + + return container + } + + private func refreshSelection() { + for item in items { + let isSelected = item.tab == selectedTab + let imageName = isSelected ? item.selectedImage : item.unselectedImage + tabButtons[item.tab]?.setImage(UIImage(named: imageName), for: .normal) + tabTitleLabels[item.tab]?.textColor = isSelected ? AppDesign.primary : UIColor(hex: 0x7D8DA3) + tabTitleLabels[item.tab]?.font = .systemFont(ofSize: 12, weight: isSelected ? .medium : .regular) + } + } + + @objc private func tabTapped(_ sender: UIButton) { + guard sender.tag >= 0, sender.tag < items.count else { return } + onTabSelected?(items[sender.tag].tab) + } + + @objc private func scanTapped() { + onScanTapped?() + } +} diff --git a/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift b/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift new file mode 100644 index 0000000..2ec5edb --- /dev/null +++ b/suixinkan_ios/Features/Main/ViewControllers/PlaceholderViewController.swift @@ -0,0 +1,41 @@ +// +// PlaceholderViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 未知或未实现路由的占位页。 +final class PlaceholderViewController: UIViewController { + + private let pageTitle: String + + init(title: String) { + pageTitle = title + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = UIColor(hex: 0xF5F7FA) + title = pageTitle + + let label = UILabel() + label.text = pageTitle + label.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .semibold) + label.textColor = AppDesign.textPrimary + label.textAlignment = .center + label.numberOfLines = 0 + view.addSubview(label) + label.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large) + } + } +} diff --git a/suixinkan_ios/Features/Main/ViewModels/MainTabBadgeViewModel.swift b/suixinkan_ios/Features/Main/ViewModels/MainTabBadgeViewModel.swift new file mode 100644 index 0000000..30bf29d --- /dev/null +++ b/suixinkan_ios/Features/Main/ViewModels/MainTabBadgeViewModel.swift @@ -0,0 +1,30 @@ +// +// MainTabBadgeViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +@MainActor +/// 主 Tab 角标 ViewModel,负责获取订单 Tab 待核销数量。 +final class MainTabBadgeViewModel { + var onChange: (() -> Void)? + private(set) var pendingWriteOffCount: Int? { didSet { onChange?() } } + + /// 刷新待核销订单数量,缺少景区或接口失败时清空角标。 + func refreshPendingWriteOffCount(api: OrderServing, scenicId: Int?, storeId: Int?) async { + guard let scenicId, scenicId > 0 else { + pendingWriteOffCount = nil + return + } + + do { + let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: 1) + pendingWriteOffCount = min(result.total, 99) + } catch { + pendingWriteOffCount = nil + } + } +} diff --git a/suixinkan_ios/Features/MessageCenter/API/MessageCenterAPI.swift b/suixinkan_ios/Features/MessageCenter/API/MessageCenterAPI.swift new file mode 100644 index 0000000..660cf43 --- /dev/null +++ b/suixinkan_ios/Features/MessageCenter/API/MessageCenterAPI.swift @@ -0,0 +1,72 @@ +// +// MessageCenterAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 消息中心服务协议,定义消息列表、已读和删除能力。 +@MainActor +protocol MessageCenterServing { + /// 获取消息列表,使用 last_id 游标分页。 + func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse + + /// 标记单条消息为已读。 + func messageRead(id: Int) async throws + + /// 删除单条消息。 + func messageDelete(id: Int) async throws +} + +@MainActor +/// 消息中心 API,封装消息相关网络请求。 +final class MessageCenterAPI { + private let client: APIClient + + /// 初始化消息中心 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取消息列表,使用 last_id 游标分页。 + func messageList(lastId: Int = 0, limit: Int = 20, unread: Int = 0) async throws -> MessageListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/msg/list", + queryItems: [ + URLQueryItem(name: "last_id", value: String(max(lastId, 0))), + URLQueryItem(name: "limit", value: String(max(limit, 1))), + URLQueryItem(name: "unread", value: String(max(unread, 0))) + ] + ) + ) + } + + /// 标记单条消息为已读。 + func messageRead(id: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/msg/read", + queryItems: [URLQueryItem(name: "id", value: String(id))], + body: EmptyPayload() + ) + ) + } + + /// 删除单条消息。 + func messageDelete(id: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/msg/delete", + body: MessageDeleteRequest(id: id) + ) + ) + } +} + +extension MessageCenterAPI: MessageCenterServing {} diff --git a/suixinkan_ios/Features/MessageCenter/MessageCenter.md b/suixinkan_ios/Features/MessageCenter/MessageCenter.md new file mode 100644 index 0000000..9191397 --- /dev/null +++ b/suixinkan_ios/Features/MessageCenter/MessageCenter.md @@ -0,0 +1,18 @@ +# 消息中心模块 + +## 模块职责 + +`Features/MessageCenter` 承接首页 `message_center` 权限入口,负责消息列表、未读筛选、标记已读、全部已读、详情展示和删除。 + +## 代码结构 + +- `MessageCenterAPI`:封装 `/api/app/msg/list`、`/api/app/msg/read`、`/api/app/msg/delete`。 +- `MessageCenterViewModel`:管理 `last_id` 游标分页、筛选、去重排序、已读和删除后的本地状态同步。 +- `MessageCenterView`:提供统计卡、全部/未读筛选、列表、失败重试、空态、详情和删除确认。 + +## 数据与边界 + +- 消息详情不请求独立详情接口,直接使用列表返回的标题、内容、类型和推送时间。 +- 点击消息时先调用已读接口,再进入详情,并将本地未读状态同步为已读。 +- 全部已读沿用旧 iOS 能力,逐条调用已读接口;任一失败时保留原状态并透出错误。 +- 首屏刷新失败会清空旧数据和分页状态,加载更多失败保留当前列表。 diff --git a/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift b/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift new file mode 100644 index 0000000..04122e8 --- /dev/null +++ b/suixinkan_ios/Features/MessageCenter/Models/MessageCenterModels.swift @@ -0,0 +1,226 @@ +// +// MessageCenterModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import UIKit + +/// 消息中心列表响应,使用 last_id 游标分页。 +struct MessageListResponse: Decodable, Equatable { + let hasMore: Bool + let lastId: Int + let items: [MessageEntity] + + enum CodingKeys: String, CodingKey { + case hasMore = "has_more" + case lastId = "last_id" + case items + } + + /// 创建空消息响应,主要用于测试和失败兜底。 + init(hasMore: Bool = false, lastId: Int = 0, items: [MessageEntity] = []) { + self.hasMore = hasMore + self.lastId = lastId + self.items = items + } + + /// 宽松解码分页状态,兼容后端数字/字符串/布尔类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hasMore = try container.decodeLossyBool(forKey: .hasMore) ?? false + lastId = try container.decodeLossyInt(forKey: .lastId) ?? 0 + items = try container.decodeIfPresent([MessageEntity].self, forKey: .items) ?? [] + } +} + +/// 后端消息实体。 +struct MessageEntity: Decodable, Equatable, Identifiable { + let id: Int + let type: Int + let typeName: String + let title: String + let content: String + let pushAt: String + let createdAt: String + let isRead: Bool + + enum CodingKeys: String, CodingKey { + case id + case type + case typeName = "type_name" + case title + case content + case pushAt = "push_at" + case createdAt = "created_at" + case isRead = "is_read" + } + + /// 创建消息实体,主要用于测试。 + init( + id: Int, + type: Int, + typeName: String = "", + title: String = "", + content: String = "", + pushAt: String = "", + createdAt: String = "", + isRead: Bool = false + ) { + self.id = id + self.type = type + self.typeName = typeName + self.title = title + self.content = content + self.pushAt = pushAt + self.createdAt = createdAt + self.isRead = isRead + } + + /// 宽松解码消息字段,避免单条异常字段导致整页失败。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + type = try container.decodeLossyInt(forKey: .type) ?? 0 + typeName = try container.decodeLossyString(forKey: .typeName) + title = try container.decodeLossyString(forKey: .title) + content = try container.decodeLossyString(forKey: .content) + pushAt = try container.decodeLossyString(forKey: .pushAt) + createdAt = try container.decodeLossyString(forKey: .createdAt) + isRead = try container.decodeLossyBool(forKey: .isRead) ?? false + } +} + +/// 删除消息请求体。 +struct MessageDeleteRequest: Encodable { + let id: Int +} + +/// 页面展示用消息类型。 +enum MessageType: Equatable { + case order + case writeOff + case system + + /// 消息类型图标。 + var iconName: String { + switch self { + case .order: + return "cart.fill" + case .writeOff: + return "qrcode.viewfinder" + case .system: + return "bell.badge.fill" + } + } + + /// 消息类型标题。 + var title: String { + switch self { + case .order: + return "订单" + case .writeOff: + return "核销" + case .system: + return "系统" + } + } + + /// 消息类型颜色。 + var color: UIColor { + switch self { + case .order: + return AppDesign.primary + case .writeOff: + return AppDesign.success + case .system: + return AppDesign.warning + } + } +} + +/// 页面展示用消息项。 +struct MessageItem: Identifiable, Equatable { + let id: String + let msgId: Int? + let title: String + let detail: String + let time: String + var isRead: Bool + let type: MessageType +} + +/// 消息筛选项。 +enum MessageFilter: Int, CaseIterable, Identifiable { + case all = 0 + case unread = 1 + + var id: Int { rawValue } + + /// 筛选标题。 + var title: String { + switch self { + case .all: + return "全部" + case .unread: + return "未读" + } + } +} + +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 ? "1" : "0" + } + return "" + } + + /// 将 String、数字和 Bool 宽松解码为 Int。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return Int(text) ?? Int(Double(text) ?? 0) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? 1 : 0 + } + return nil + } + + /// 将 String、数字和 Bool 宽松解码为 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), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !text.isEmpty { + if ["1", "true", "yes"].contains(text) { return true } + if ["0", "false", "no"].contains(text) { return false } + } + return nil + } +} diff --git a/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift b/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift new file mode 100644 index 0000000..dc583e2 --- /dev/null +++ b/suixinkan_ios/Features/MessageCenter/ViewControllers/MessageCenterViewController.swift @@ -0,0 +1,69 @@ +// +// MessageCenterViewController.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 消息中心列表页。 +final class MessageCenterViewController: ModuleTableViewController { + private let viewModel = MessageCenterViewModel() + + override func viewDidLoad() { + title = "消息中心" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "全部已读", + style: .plain, + target: self, + action: #selector(markAllRead) + ) + super.viewDidLoad() + wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() } + } + + override func tableRowCount() -> Int { + viewModel.messages.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.messages[indexPath.row] + cell.configure( + title: item.title, + subtitle: item.detail, + detail: item.isRead ? item.time : "未读 · \(item.time)" + ) + cell.accessoryType = item.isRead ? .none : .disclosureIndicator + } + + override func didSelectTableRow(at indexPath: IndexPath) { + let item = viewModel.messages[indexPath.row] + Task { + try? await viewModel.markAsRead(api: services.messageCenterAPI, item: item) + showAlert(title: item.title, message: item.detail) + } + } + + override func reloadContent() async { + isLoading = viewModel.messages.isEmpty + await viewModel.reloadFirstPage(api: services.messageCenterAPI) + isLoading = false + } + + @objc private func markAllRead() { + Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) } + } + + private func updateBarButtons() { + navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty + } + + private func showAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "知道了", style: .default)) + present(alert, animated: true) + } +} + +extension MessageCenterViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift b/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift new file mode 100644 index 0000000..d92287a --- /dev/null +++ b/suixinkan_ios/Features/MessageCenter/ViewModels/MessageCenterViewModel.swift @@ -0,0 +1,169 @@ +// +// MessageCenterViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +@MainActor +/// 消息中心 ViewModel,负责消息分页、筛选、已读和删除。 +final class MessageCenterViewModel { + var onChange: (() -> Void)? + var messages: [MessageItem] = [] { didSet { onChange?() } } + var selectedFilter: MessageFilter = .all { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var loadFailed = false { didSet { onChange?() } } + var loadFailureReason: String? { didSet { onChange?() } } + var message: String? { didSet { onChange?() } } + + private(set) var hasMoreMessages = false { didSet { onChange?() } } + private(set) var lastId = 0 { didSet { onChange?() } } + private let pageSize = 20 + + /// 未读消息数量。 + var unreadCount: Int { + messages.filter { !$0.isRead }.count + } + + /// 当前筛选说明。 + var filterDescription: String { + selectedFilter == .all ? "显示全部消息" : "仅显示未读消息" + } + + /// 切换筛选并重新加载首屏。 + func selectFilter(_ filter: MessageFilter, api: any MessageCenterServing) async { + guard selectedFilter != filter else { return } + selectedFilter = filter + await reloadFirstPage(api: api) + } + + /// 重新加载首屏消息,失败时清空旧数据。 + func reloadFirstPage(api: any MessageCenterServing) async { + isLoading = true + loadFailed = false + loadFailureReason = nil + message = nil + defer { isLoading = false } + + do { + let response = try await api.messageList(lastId: 0, limit: pageSize, unread: selectedFilter.rawValue) + messages = mapMessages(response.items) + hasMoreMessages = response.hasMore + lastId = response.lastId + } catch { + clearMessages() + loadFailed = true + loadFailureReason = error.localizedDescription + message = error.localizedDescription + } + } + + /// 加载更多消息,失败时保留当前数据和分页状态。 + func loadMore(api: any MessageCenterServing) async { + guard hasMoreMessages, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + message = nil + defer { isLoadingMore = false } + + do { + let response = try await api.messageList(lastId: lastId, limit: pageSize, unread: selectedFilter.rawValue) + messages.append(contentsOf: mapMessages(response.items)) + messages = deduplicatedAndSorted(messages) + hasMoreMessages = response.hasMore + lastId = response.lastId + } catch { + message = error.localizedDescription + } + } + + /// 标记单条消息为已读,接口成功后再更新本地状态。 + func markAsRead(api: any MessageCenterServing, item: MessageItem) async throws { + guard !item.isRead, let msgId = item.msgId else { return } + try await api.messageRead(id: msgId) + guard let index = messages.firstIndex(where: { $0.id == item.id }) else { return } + messages[index].isRead = true + } + + /// 将当前列表中的未读消息逐条标记为已读。 + func markAllAsRead(api: any MessageCenterServing) async throws { + let original = messages + do { + for item in messages where !item.isRead { + if let msgId = item.msgId { + try await api.messageRead(id: msgId) + } + } + messages = messages.map { item in + var next = item + next.isRead = true + return next + } + } catch { + messages = original + throw error + } + } + + /// 删除消息,接口成功后再移除本地列表。 + func deleteMessage(api: any MessageCenterServing, item: MessageItem) async throws { + if let msgId = item.msgId { + try await api.messageDelete(id: msgId) + } + messages.removeAll { $0.id == item.id } + } + + /// 将后端实体转换为页面展示实体。 + func mapMessages(_ source: [MessageEntity]) -> [MessageItem] { + source.map { item in + MessageItem( + id: "msg_\(item.id)", + msgId: item.id, + title: item.title.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? item.typeName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? "系统通知", + detail: item.content, + time: item.pushAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? item.createdAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? "0000-00-00 00:00", + isRead: item.isRead, + type: Self.messageType(from: item.type) + ) + } + } + + /// 将后端类型映射为页面消息类型。 + static func messageType(from value: Int) -> MessageType { + switch value { + case 1: + return .order + case 2: + return .writeOff + default: + return .system + } + } + + private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] { + var unique: [String: MessageItem] = [:] + for item in source { + unique[item.id] = item + } + return unique.values.sorted { $0.time > $1.time } + } + + private func clearMessages() { + messages = [] + hasMoreMessages = false + lastId = 0 + isLoadingMore = false + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift b/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift new file mode 100644 index 0000000..9e62124 --- /dev/null +++ b/suixinkan_ios/Features/OperatingArea/API/OperatingAreaAPI.swift @@ -0,0 +1,50 @@ +// +// OperatingAreaAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 运营区域服务协议,定义店铺和景区管理员两类围栏数据接口。 +@MainActor +protocol OperatingAreaServing { + func storeBusinessArea(storeId: Int) async throws -> ListPayload + func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload +} + +@MainActor +/// 运营区域 API,封装运营区域只读围栏接口。 +final class OperatingAreaAPI { + private let client: APIClient + + /// 初始化运营区域 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取当前店铺的运营区域围栏。 + func storeBusinessArea(storeId: Int) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/business-area", + queryItems: [URLQueryItem(name: "store_id", value: String(storeId))] + ) + ) + } + + /// 获取景区管理员视角下的运营区域围栏。 + func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-admin/business-area", + queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))] + ) + ) + } +} + +extension OperatingAreaAPI: OperatingAreaServing {} diff --git a/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift b/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift new file mode 100644 index 0000000..6ed6754 --- /dev/null +++ b/suixinkan_ios/Features/OperatingArea/Models/OperatingAreaModels.swift @@ -0,0 +1,374 @@ +// +// OperatingAreaModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 运营区域入口模式,区分店铺管理员与景区管理员两条接口链路。 +enum OperatingAreaEntryMode: Equatable { + case storeAdmin(storeId: Int) + case scenicAdmin(scenicId: Int) + + var title: String { + switch self { + case .storeAdmin: + "店铺运营区域" + case .scenicAdmin: + "景区运营区域" + } + } +} + +/// 运营区域接口单项,表示一个门店或区域围栏。 +struct OperatingAreaItem: Decodable, Equatable, Identifiable { + let id: Int + let name: String + let businessMapArea: OperatingMapArea? + let statusText: String + let typeText: String + let auditStatusText: String + + enum CodingKeys: String, CodingKey { + case id + case name + case businessMapArea = "business_map_area" + case statusText = "status_text" + case typeText = "type_text" + case auditStatusText = "audit_status_text" + } + + init( + id: Int = 0, + name: String = "", + businessMapArea: OperatingMapArea? = nil, + statusText: String = "", + typeText: String = "", + auditStatusText: String = "" + ) { + self.id = id + self.name = name + self.businessMapArea = businessMapArea + self.statusText = statusText + self.typeText = typeText + self.auditStatusText = auditStatusText + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.operatingDecodeLossyInt(forKey: .id) ?? 0 + name = try container.operatingDecodeLossyString(forKey: .name) + businessMapArea = try container.decodeIfPresent(OperatingMapArea.self, forKey: .businessMapArea) + statusText = try container.operatingDecodeLossyString(forKey: .statusText) + typeText = try container.operatingDecodeLossyString(forKey: .typeText) + auditStatusText = try container.operatingDecodeLossyString(forKey: .auditStatusText) + } +} + +/// 运营区域原始 JSON 值,用于保留并解析后端不稳定的 `business_map_area`。 +enum OperatingMapArea: Decodable, Equatable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([OperatingMapArea]) + case object([String: OperatingMapArea]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([OperatingMapArea].self) { + self = .array(value) + } else { + let keyed = try decoder.container(keyedBy: OperatingDynamicCodingKey.self) + var object: [String: OperatingMapArea] = [:] + for key in keyed.allKeys { + object[key.stringValue] = try keyed.decode(OperatingMapArea.self, forKey: key) + } + self = .object(object) + } + } + + /// 将 JSON 字符串解析为运营区域 JSON 值。 + static func parseString(_ text: String) -> OperatingMapArea? { + guard let data = text.data(using: .utf8), + let raw = try? JSONSerialization.jsonObject(with: data) + else { return nil } + return fromJSONObject(raw) + } + + private static func fromJSONObject(_ value: Any) -> OperatingMapArea { + switch value { + case is NSNull: + return .null + case let value as Bool: + return .bool(value) + case let value as NSNumber: + return .number(value.doubleValue) + case let value as String: + return .string(value) + case let value as [Any]: + return .array(value.map(fromJSONObject)) + case let value as [String: Any]: + return .object(value.mapValues(fromJSONObject)) + default: + return .null + } + } +} + +/// 运营区域地理坐标点。 +struct OperatingGeoPoint: Equatable, Hashable { + let latitude: Double + let longitude: Double +} + +/// 运营区域围栏环,用于地图绘制和列表展示。 +struct OperatingFenceRing: Identifiable, Equatable { + let id: String + let itemId: Int + let regionName: String + let points: [OperatingGeoPoint] + let isCurrentStore: Bool + + init(itemId: Int, regionName: String, points: [OperatingGeoPoint], isCurrentStore: Bool, ringIndex: Int) { + self.id = "\(itemId)-\(ringIndex)" + self.itemId = itemId + self.regionName = regionName + self.points = points + self.isCurrentStore = isCurrentStore + } +} + +/// 运营区域阻断原因,用于缺少上下文或数据不可展示时给出稳定提示。 +enum OperatingAreaBlockReason: Equatable { + case missingStore + case missingScenic + case unsupportedRole + case emptyAreaList + case noParsableFenceData + case backendMessage(String) + + var message: String { + switch self { + case .missingStore: + "请先选择店铺" + case .missingScenic: + "请先选择景区" + case .unsupportedRole: + "当前账号暂不支持运营区域" + case .emptyAreaList: + "当前景区无区域数据" + case .noParsableFenceData: + "有区域记录,但未解析出有效围栏,请检查坐标数据" + case .backendMessage(let message): + message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "运营区域加载失败" : message + } + } +} + +/// 运营区域围栏解析器,兼容 GeoJSON、数组、字符串 JSON 和常见路径字段。 +enum OperatingAreaParser { + /// 将后端 `business_map_area` 解析为若干闭合多边形环。 + static func parseToRings(_ area: OperatingMapArea?) -> [[OperatingGeoPoint]] { + guard let area else { return [] } + return parseElement(area) + } + + private static func parseElement(_ value: OperatingMapArea) -> [[OperatingGeoPoint]] { + switch value { + case .null, .bool, .number: + return [] + case .string(let text): + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let parsed = OperatingMapArea.parseString(trimmed) else { return [] } + return parseElement(parsed) + case .array(let array): + return parseArrayRoot(array) + case .object(let object): + return parseObject(object) + } + } + + private static func parseObject(_ object: [String: OperatingMapArea]) -> [[OperatingGeoPoint]] { + let type: String? + if case let .string(rawType)? = object["type"] { + type = rawType.lowercased() + } else { + type = nil + } + + switch type { + case "polygon": + guard case let .array(coordinates)? = object["coordinates"], + case let .array(outer)? = coordinates.first + else { return [] } + return parseRing(outer).map { [$0] } ?? [] + case "multipolygon": + guard case let .array(polygons)? = object["coordinates"] else { return [] } + return polygons.compactMap { polygon -> [OperatingGeoPoint]? in + guard case let .array(rings) = polygon, + case let .array(firstRing)? = rings.first + else { return nil } + return parseRing(firstRing) + } + default: + for key in ["path", "paths", "points", "coordinates"] { + if let nested = object[key] { + return parseElement(nested) + } + } + return [] + } + } + + private static func parseArrayRoot(_ array: [OperatingMapArea]) -> [[OperatingGeoPoint]] { + guard let first = array.first else { return [] } + switch first { + case .object: + return parseObjectRing(array).map { [$0] } ?? [] + case .array(let nested): + guard let sample = nested.first else { return [] } + switch sample { + case .array(let pointCandidate): + if pointCandidate.count >= 2, pointCandidate[0].numberValue != nil, pointCandidate[1].numberValue != nil { + return parseRing(nested).map { [$0] } ?? [] + } + return array.compactMap { element -> [OperatingGeoPoint]? in + guard case let .array(segment) = element else { return nil } + return parseRing(segment) + } + case .object: + return parseObjectRing(nested).map { [$0] } ?? [] + default: + return parseRing(nested).map { [$0] } ?? [] + } + default: + return [] + } + } + + private static func parseObjectRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { + let points = array.compactMap { element -> OperatingGeoPoint? in + guard case let .object(object) = element else { return nil } + return pointFromObject(object) + } + return points.count >= 3 ? points : nil + } + + private static func parseRing(_ array: [OperatingMapArea]) -> [OperatingGeoPoint]? { + let points = array.compactMap { element -> OperatingGeoPoint? in + switch element { + case .array(let pair): + guard pair.count >= 2, + let a = pair[0].numberValue, + let b = pair[1].numberValue + else { return nil } + return normalizePair(a, b) + case .object(let object): + return pointFromObject(object) + default: + return nil + } + } + return points.count >= 3 ? points : nil + } + + private static func pointFromObject(_ object: [String: OperatingMapArea]) -> OperatingGeoPoint? { + let lat = object["lat"]?.numberValue ?? object["latitude"]?.numberValue + let lng = object["lng"]?.numberValue ?? object["lon"]?.numberValue ?? object["longitude"]?.numberValue + guard let lat, let lng else { return nil } + return OperatingGeoPoint(latitude: lat, longitude: lng) + } + + private static func normalizePair(_ a: Double, _ b: Double) -> OperatingGeoPoint { + if looksLikeLngLatPair(lng: a, lat: b) { + return OperatingGeoPoint(latitude: b, longitude: a) + } + if looksLikeLatLngPair(lat: a, lng: b) { + return OperatingGeoPoint(latitude: a, longitude: b) + } + if abs(a) > 90 { + return OperatingGeoPoint(latitude: b, longitude: a) + } + return OperatingGeoPoint(latitude: a, longitude: b) + } + + private static func looksLikeLngLatPair(lng: Double, lat: Double) -> Bool { + (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lng) > abs(lat) + } + + private static func looksLikeLatLngPair(lat: Double, lng: Double) -> Bool { + (-90.0...90.0).contains(lat) && abs(lng) <= 180.0 && abs(lat) <= abs(lng) + } +} + +private struct OperatingDynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue: Int) { + stringValue = String(intValue) + self.intValue = intValue + } +} + +private extension OperatingMapArea { + var numberValue: Double? { + switch self { + case .number(let value): + return value + case .string(let text): + return Double(text.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + return nil + } + } +} + +private extension KeyedDecodingContainer { + func operatingDecodeLossyString(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 "" + } + + func operatingDecodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { return intValue } + if let doubleValue = Double(text) { return Int(doubleValue) } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan_ios/Features/OperatingArea/OperatingArea.md b/suixinkan_ios/Features/OperatingArea/OperatingArea.md new file mode 100644 index 0000000..c2f82ba --- /dev/null +++ b/suixinkan_ios/Features/OperatingArea/OperatingArea.md @@ -0,0 +1,17 @@ +# OperatingArea 模块业务逻辑 + +## 模块职责 + +OperatingArea 负责首页 `operating-area` 入口的运营区域展示。模块按当前角色和业务上下文进入店铺管理员或景区管理员模式,读取后端围栏数据并只读展示。 + +## 核心流程 + +- `OperatingAreaView` 从环境读取 `AccountContext`、`PermissionContext` 和 `OperatingAreaAPI`。 +- `OperatingAreaViewModel` 根据角色名称和当前景区/门店解析入口模式。 +- 店铺管理员调用 `/api/app/store/business-area`,景区管理员调用 `/api/app/scenic-admin/business-area`。 +- `OperatingAreaParser` 解析 `business_map_area`,兼容 GeoJSON、坐标数组和字符串 JSON。 +- 真机构建启用高德地图时绘制 polygon;模拟器或未启用高德时展示坐标摘要兜底。 + +## 边界 + +本模块只展示已有区域和围栏,不提供新建、编辑、删除或绘制保存能力。缺少景区/门店、角色不支持、空列表和不可解析围栏都会进入明确空态或阻断提示。 diff --git a/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift b/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift new file mode 100644 index 0000000..bc58b77 --- /dev/null +++ b/suixinkan_ios/Features/OperatingArea/ViewControllers/OperatingAreaViewController.swift @@ -0,0 +1,38 @@ +// +// OperatingAreaViewController.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 运营区域列表页。 +final class OperatingAreaViewController: ModuleTableViewController { + private let viewModel = OperatingAreaViewModel() + + override func viewDidLoad() { + title = "运营区域" + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { + viewModel.items.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + cell.configure(title: item.name, subtitle: "\(item.typeText) · \(item.statusText)", detail: "围栏 \(viewModel.fenceRings.count) 组") + } + + override func reloadContent() async { + await viewModel.reload( + api: services.operatingAreaAPI, + accountContext: services.accountContext, + permissionContext: services.permissionContext + ) + } +} + +extension OperatingAreaViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift b/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift new file mode 100644 index 0000000..2c6b8f8 --- /dev/null +++ b/suixinkan_ios/Features/OperatingArea/ViewModels/OperatingAreaViewModel.swift @@ -0,0 +1,153 @@ +// +// OperatingAreaViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +@MainActor +/// 运营区域 ViewModel,负责角色分流、围栏加载、解析和错误状态管理。 +final class OperatingAreaViewModel { + var onChange: (() -> Void)? + private(set) var mode: OperatingAreaEntryMode? { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var items: [OperatingAreaItem] = [] { didSet { onChange?() } } + private(set) var fenceRings: [OperatingFenceRing] = [] { didSet { onChange?() } } + private(set) var blockReason: OperatingAreaBlockReason? { didSet { onChange?() } } + private(set) var errorMessage: String? { didSet { onChange?() } } + + /// 按当前账号和角色刷新运营区域。 + func reload( + api: any OperatingAreaServing, + accountContext: AccountContext, + permissionContext: PermissionContext + ) async { + guard !loading else { return } + guard let nextMode = resolveMode(accountContext: accountContext, permissionContext: permissionContext) else { + return + } + + mode = nextMode + loading = true + errorMessage = nil + blockReason = nil + defer { loading = false } + + do { + let response: ListPayload + switch nextMode { + case .storeAdmin(let storeId): + response = try await api.storeBusinessArea(storeId: storeId) + case .scenicAdmin(let scenicId): + response = try await api.scenicAdminBusinessArea(scenicId: scenicId) + } + apply(items: response.list, mode: nextMode) + } catch { + resetLoadedData() + let message = error.localizedDescription + errorMessage = message + blockReason = .backendMessage(message) + } + } + + /// 根据当前上下文解析运营区域入口模式。 + @discardableResult + func resolveMode(accountContext: AccountContext, permissionContext: PermissionContext) -> OperatingAreaEntryMode? { + let roleName = permissionContext.currentRole?.name.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let lowercaseRoleName = roleName.lowercased() + let isStoreRole = roleName.contains("店铺") || roleName.contains("门店") || lowercaseRoleName.contains("store") + let isScenicRole = roleName.contains("景区") || lowercaseRoleName.contains("scenic") + + if isStoreRole { + guard let storeId = accountContext.currentStore?.id, storeId > 0 else { + setBlocked(.missingStore) + return nil + } + mode = .storeAdmin(storeId: storeId) + blockReason = nil + return mode + } + + if isScenicRole { + guard let scenicId = accountContext.currentScenic?.id, scenicId > 0 else { + setBlocked(.missingScenic) + return nil + } + mode = .scenicAdmin(scenicId: scenicId) + blockReason = nil + return mode + } + + if let storeId = accountContext.currentStore?.id, storeId > 0 { + mode = .storeAdmin(storeId: storeId) + blockReason = nil + return mode + } + + if let scenicId = accountContext.currentScenic?.id, scenicId > 0 { + mode = .scenicAdmin(scenicId: scenicId) + blockReason = nil + return mode + } + + setBlocked(.unsupportedRole) + return nil + } + + /// 根据状态生成统计展示。 + var summary: (areaCount: Int, fenceCount: Int, currentStoreFenceCount: Int) { + ( + areaCount: items.count, + fenceCount: fenceRings.count, + currentStoreFenceCount: fenceRings.filter(\.isCurrentStore).count + ) + } + + /// 当前页面标题。 + var title: String { + mode?.title ?? "运营区域" + } + + private func apply(items: [OperatingAreaItem], mode: OperatingAreaEntryMode) { + self.items = items + if items.isEmpty { + fenceRings = [] + blockReason = .emptyAreaList + return + } + + let currentStoreId: Int? = { + guard case .storeAdmin(let storeId) = mode else { return nil } + return storeId + }() + + let parsed = items.flatMap { item -> [OperatingFenceRing] in + OperatingAreaParser.parseToRings(item.businessMapArea).enumerated().compactMap { index, points in + guard points.count >= 3 else { return nil } + return OperatingFenceRing( + itemId: item.id, + regionName: item.name, + points: points, + isCurrentStore: currentStoreId == item.id, + ringIndex: index + ) + } + } + fenceRings = parsed + blockReason = parsed.isEmpty ? .noParsableFenceData : nil + } + + private func setBlocked(_ reason: OperatingAreaBlockReason) { + resetLoadedData() + blockReason = reason + errorMessage = nil + loading = false + } + + private func resetLoadedData() { + items = [] + fenceRings = [] + } +} diff --git a/suixinkan_ios/Features/Orders/API/OrdersAPI.swift b/suixinkan_ios/Features/Orders/API/OrdersAPI.swift new file mode 100644 index 0000000..cc1bb4b --- /dev/null +++ b/suixinkan_ios/Features/Orders/API/OrdersAPI.swift @@ -0,0 +1,258 @@ +// +// OrdersAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 订单服务协议,抽象订单列表、核销列表和核销操作以便测试替换。 +protocol OrderServing { + /// 获取订单管理列表。 + func orderList( + scenicId: Int, + page: Int, + pageSize: Int, + orderStatus: Int?, + userPhone: String?, + startTime: String?, + endTime: String?, + isRefined: Int?, + isScenicAdmin: Bool + ) async throws -> ListPayload + + /// 获取核销订单列表。 + func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload + + /// 核销指定订单号。 + func writeOff(orderNumber: String) async throws + + /// 获取门店订单详情。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse + + /// 获取押金订单列表。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload + + /// 核销押金订单。 + func depositOrderWriteOff(orderNumber: String) async throws + + /// 申请押金订单退款。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws + + /// 获取订单某个拍摄点的底片、成片和评价信息。 + func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse + + /// 申请普通订单退款。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws + + /// 获取订单历史拍摄信息。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse + + /// 获取多点旅拍订单已核销打卡点列表。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] + + /// 提交多点旅拍任务素材。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws +} + +@MainActor +/// 订单 API,封装订单管理、核销订单和订单核销接口。 +final class OrdersAPI: OrderServing { + private let client: APIClient + + /// 初始化订单 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取订单管理列表。 + func orderList( + scenicId: Int, + page: Int = 1, + pageSize: Int = 20, + orderStatus: Int? = nil, + userPhone: String? = nil, + startTime: String? = nil, + endTime: String? = nil, + isRefined: Int? = nil, + isScenicAdmin: Bool = false + ) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let orderStatus, orderStatus > 0 { + query.append(URLQueryItem(name: "order_status", value: "\(orderStatus)")) + } + if let userPhone, !userPhone.isEmpty { + query.append(URLQueryItem(name: "user_phone", value: userPhone)) + } + if let startTime, !startTime.isEmpty { + query.append(URLQueryItem(name: "start_time", value: startTime)) + } + if let endTime, !endTime.isEmpty { + query.append(URLQueryItem(name: "end_time", value: endTime)) + } + if let isRefined, isRefined > 0 { + query.append(URLQueryItem(name: "is_refined", value: "\(isRefined)")) + } + + return try await client.send( + APIRequest( + method: .get, + path: isScenicAdmin ? "/api/app/scenic-admin/order/list" : "/api/yf-handset-app/photog/order/listv2", + queryItems: query + ) + ) + } + + /// 获取核销订单列表。 + func writeOffList(scenicId: Int, storeId: Int? = nil, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload { + var queryItems = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let storeId, storeId > 0 { + queryItems.append(URLQueryItem(name: "store_id", value: "\(storeId)")) + } + + return try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/order/order-verification-list", + queryItems: queryItems + ) + ) + } + + /// 核销指定订单号。 + func writeOff(orderNumber: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/order-verify", + body: WriteOffRequest(orderNumber: orderNumber) + ) + ) + } + + /// 获取门店订单详情。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/order/detail", + queryItems: [ + URLQueryItem(name: "store_id", value: "\(storeId)"), + URLQueryItem(name: "order_number", value: orderNumber) + ] + ) + ) + } + + /// 获取押金订单列表。 + func depositOrderList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/order/deposit-list", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } + + /// 核销押金订单。 + func depositOrderWriteOff(orderNumber: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/deposit-writeoff", + body: DepositOrderWriteOffRequest(orderNumber: orderNumber) + ) + ) + } + + /// 申请押金订单退款。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/deposit-refund", + body: DepositOrderRefundRequest(orderNumber: orderNumber, refundReason: refundReason) + ) + ) + } + + /// 获取订单某个拍摄点的底片、成片和评价信息。 + func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store/order/shooting-detail", + queryItems: [ + URLQueryItem(name: "store_id", value: "\(storeId)"), + URLQueryItem(name: "order_number", value: orderNumber), + URLQueryItem(name: "scenic_spot_id", value: "\(scenicSpotId)"), + URLQueryItem(name: "photog_uid", value: "\(photogUid)") + ] + ) + ) + } + + /// 申请普通订单退款。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/refund", + body: OrderRefundRequest( + orderNumber: orderNumber, + refundType: refundType.rawValue, + refundAmount: refundAmount, + refundReason: refundReason + ) + ) + ) + } + + /// 获取订单历史拍摄信息。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/order/multi-travel/shoot-history", + queryItems: [URLQueryItem(name: "order_number", value: orderNumber)] + ) + ) + } + + /// 获取多点旅拍订单已核销打卡点列表。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list", + queryItems: [URLQueryItem(name: "order_number", value: orderNumber)] + ) + ) + } + + /// 提交多点旅拍任务素材。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/order/multi-travel/upload-material", + body: request + ) + ) + } +} diff --git a/suixinkan_ios/Features/Orders/Models/OrderModels.swift b/suixinkan_ios/Features/Orders/Models/OrderModels.swift new file mode 100644 index 0000000..1d33233 --- /dev/null +++ b/suixinkan_ios/Features/Orders/Models/OrderModels.swift @@ -0,0 +1,835 @@ +// +// OrderModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 订单 Tab 内的子入口,表示订单管理或核销订单。 +enum OrdersEntry: Hashable { + case storeOrders + case verificationOrders +} + +/// 订单状态筛选实体,表示订单管理列表顶部的状态过滤项。 +struct OrderStatusFilter: Identifiable, Hashable { + let id: Int + let title: String +} + +/// 订单筛选常量,集中维护旧工程同步过来的订单状态。 +enum OrderFilters { + static let statusFilters: [OrderStatusFilter] = [ + .init(id: 0, title: "全部"), + .init(id: 18, title: "已付定金"), + .init(id: 30, title: "已完成"), + .init(id: 50, title: "已退款"), + .init(id: -1, title: "需要精修"), + .init(id: -2, title: "无需精修") + ] +} + +/// 订单管理列表实体,承载订单卡片展示和筛选判断所需字段。 +struct OrderEntity: Decodable, Identifiable, Equatable, Hashable { + var id: String { orderNumber } + + let photogUid: Int + let orderNumber: String + let remark: String + let scenicAreaId: Int + let payTime: String + let completeTime: String + let orderStatus: Int + let orderStatusName: String + let storeId: Int? + let userId: Int + let projectId: Int + let phone: String + let orderAmount: String + let actualPayAmount: String + let actualRefundAmount: String + let refundAmount: String + let payTypeName: String + let payType: Int + let projectName: String + let orderType: Int + let orderTypeLabel: String + let depositPayTime: String + let createdAt: String + let photoTravel: OrderPhotoTravel? + let isNeedEdit: Bool + let isRefined: Int + + enum CodingKeys: String, CodingKey { + case photogUid = "photog_uid" + case orderNumber = "order_number" + case remark + case scenicAreaId = "scenic_area_id" + case payTime = "pay_time" + case completeTime = "complete_time" + case orderStatus = "order_status" + case orderStatusName = "order_status_name" + case storeId = "store_id" + case userId = "user_id" + case projectId = "project_id" + case phone + case orderAmount = "order_amount" + case actualPayAmount = "actual_pay_amount" + case actualRefundAmount = "actual_refund_amount" + case refundAmount = "refund_amount" + case payTypeName = "pay_type_name" + case payType = "pay_type" + case projectName = "project_name" + case orderType = "order_type" + case orderTypeLabel = "order_type_label" + case depositPayTime = "deposit_pay_time" + case createdAt = "created_at" + case photoTravel = "photo_travel" + case isNeedEdit + case isRefined = "is_refined" + } + + /// 宽松解码订单字段,兼容后端数字和字符串混用。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0 + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + remark = try container.decodeLossyString(forKey: .remark) + scenicAreaId = try container.decodeLossyInt(forKey: .scenicAreaId) ?? 0 + payTime = try container.decodeLossyString(forKey: .payTime) + completeTime = try container.decodeLossyString(forKey: .completeTime) + orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0 + orderStatusName = try container.decodeLossyString(forKey: .orderStatusName) + storeId = try container.decodeLossyInt(forKey: .storeId) + userId = try container.decodeLossyInt(forKey: .userId) ?? 0 + projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0 + phone = try container.decodeLossyString(forKey: .phone) + orderAmount = try container.decodeLossyString(forKey: .orderAmount) + actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount) + actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount) + refundAmount = try container.decodeLossyString(forKey: .refundAmount) + payTypeName = try container.decodeLossyString(forKey: .payTypeName) + payType = try container.decodeLossyInt(forKey: .payType) ?? 0 + projectName = try container.decodeLossyString(forKey: .projectName) + orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0 + orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel) + depositPayTime = try container.decodeLossyString(forKey: .depositPayTime) + createdAt = try container.decodeLossyString(forKey: .createdAt) + photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel) + isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true + isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0 + } +} + +/// 旅拍订单附加信息实体,表示修图、视频和核销相关数量。 +struct OrderPhotoTravel: Decodable, Equatable, Hashable { + let orderPhotoNum: Int + let orderVideoNum: Int + let checkInTime: String + let retouchGiftPhotoNum: Int + let retouchGiftVideoNum: Int + + enum CodingKeys: String, CodingKey { + case orderPhotoNum = "order_photo_num" + case orderVideoNum = "order_video_num" + case checkInTime = "check_in_time" + case retouchGiftPhotoNum = "retouch_gift_photo_num" + case retouchGiftVideoNum = "retouch_gift_video_num" + } + + /// 宽松解码旅拍附加信息。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderPhotoNum = try container.decodeLossyInt(forKey: .orderPhotoNum) ?? 0 + orderVideoNum = try container.decodeLossyInt(forKey: .orderVideoNum) ?? 0 + checkInTime = try container.decodeLossyString(forKey: .checkInTime) + retouchGiftPhotoNum = try container.decodeLossyInt(forKey: .retouchGiftPhotoNum) ?? 0 + retouchGiftVideoNum = try container.decodeLossyInt(forKey: .retouchGiftVideoNum) ?? 0 + } +} + +/// 核销订单列表实体,表示可核销订单卡片的展示和操作状态。 +struct WriteOffOrderItem: Decodable, Identifiable, Equatable, Hashable { + var id: String { orderNumber } + + let orderNumber: String + let orderVerificationStatus: String + let payTime: String + let projectName: String + let userPhone: String + let orderAmount: String + let orderStatusName: String + let orderVerificationTime: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case orderVerificationStatus = "order_verification_status" + case payTime = "pay_time" + case projectName = "project_name" + case userPhone = "user_phone" + case orderAmount = "order_amount" + case orderStatusName = "order_status_name" + case orderVerificationTime = "order_verification_time" + } + + /// 宽松解码核销订单字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + orderVerificationStatus = try container.decodeLossyString(forKey: .orderVerificationStatus) + payTime = try container.decodeLossyString(forKey: .payTime) + projectName = try container.decodeLossyString(forKey: .projectName) + userPhone = try container.decodeLossyString(forKey: .userPhone) + orderAmount = try container.decodeLossyString(forKey: .orderAmount) + orderStatusName = try container.decodeLossyString(forKey: .orderStatusName) + orderVerificationTime = try container.decodeLossyString(forKey: .orderVerificationTime) + } +} + +/// 核销订单请求实体,提交手动输入的订单号。 +struct WriteOffRequest: Encodable { + let orderNumber: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + } +} + +/// 押金订单列表实体,表示首页押金订单卡片的基础信息和操作状态。 +struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable { + let id: Int + let orderNumber: String + let userPhone: String + let amount: String + let status: Int + let statusName: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case orderNumber = "order_number" + case userPhone = "user_phone" + case amount + case status + case statusName = "status_name" + case createdAt = "created_at" + } + + /// 宽松解码押金订单字段,兼容后端数字和字符串混用。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + userPhone = try container.decodeLossyString(forKey: .userPhone) + amount = try container.decodeLossyString(forKey: .amount) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusName = try container.decodeLossyString(forKey: .statusName) + createdAt = try container.decodeLossyString(forKey: .createdAt) + } +} + +/// 押金订单核销请求实体。 +struct DepositOrderWriteOffRequest: Encodable, Equatable { + let orderNumber: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + } +} + +/// 押金订单退款请求实体。 +struct DepositOrderRefundRequest: Encodable, Equatable { + let orderNumber: String + let refundReason: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case refundReason = "refund_reason" + } +} + +/// 普通订单退款模式,表示全额退款或部分退款。 +enum OrderRefundMode: Int, CaseIterable, Identifiable, Hashable { + case full = 1 + case partial = 3 + + var id: Int { rawValue } + + /// 退款模式展示文案。 + var title: String { + switch self { + case .full: + return "全额退款" + case .partial: + return "部分退款" + } + } +} + +/// 普通订单退款请求实体。 +struct OrderRefundRequest: Encodable, Equatable { + let orderNumber: String + let refundType: Int + let refundAmount: String + let refundReason: String + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case refundType = "refund_type" + case refundAmount = "refund_amount" + case refundReason = "refund_reason" + } +} + +/// 订单详情路由实体,表示订单模块内部可 push 的二级页面。 +enum OrdersRoute: Hashable { + case storeDetail(OrderEntity) + case writeOffDetail(WriteOffOrderItem) + case depositDetail(orderNumber: String) + case depositShootingInfo(orderNumber: String, scenicSpotId: Int, photogUid: Int) + case historicalShooting(orderNumber: String) + case multiTravelTaskUpload(orderNumber: String) + case orderTrailer(orderNumber: String, title: String) +} + +/// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。 +struct StoreOrderDetailResponse: Decodable, Equatable, Hashable { + let orderNumber: String + let orderType: Int + let orderTypeLabel: String + let createdAt: String + let orderStatus: Int + let orderStatusName: String + let actualPayAmount: String + let actualRefundAmount: String + let payTypeName: String + let payTime: String + let completeTime: String + let userId: Int + let phone: String + let projectId: Int + let projectName: String + let remark: String + let multiTravel: StoreOrderMultiTravelInfo? + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case orderType = "order_type" + case orderTypeLabel = "order_type_label" + case createdAt = "created_at" + case orderStatus = "order_status" + case orderStatusName = "order_status_name" + case actualPayAmount = "actual_pay_amount" + case actualRefundAmount = "actual_refund_amount" + case payTypeName = "pay_type_name" + case payTime = "pay_time" + case completeTime = "complete_time" + case userId = "user_id" + case phone + case projectId = "project_id" + case projectName = "project_name" + case remark + case multiTravel = "multi_travel" + } + + /// 宽松解码订单详情字段,兼容后端数字和字符串混用。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0 + orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel) + createdAt = try container.decodeLossyString(forKey: .createdAt) + orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0 + orderStatusName = try container.decodeLossyString(forKey: .orderStatusName) + actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount) + actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount) + payTypeName = try container.decodeLossyString(forKey: .payTypeName) + payTime = try container.decodeLossyString(forKey: .payTime) + completeTime = try container.decodeLossyString(forKey: .completeTime) + userId = try container.decodeLossyInt(forKey: .userId) ?? 0 + phone = try container.decodeLossyString(forKey: .phone) + projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0 + projectName = try container.decodeLossyString(forKey: .projectName) + remark = try container.decodeLossyString(forKey: .remark) + multiTravel = try container.decodeIfPresent(StoreOrderMultiTravelInfo.self, forKey: .multiTravel) + } +} + +/// 多景点旅拍详情实体,表示项目配置和各拍摄点状态。 +struct StoreOrderMultiTravelInfo: Decodable, Equatable, Hashable { + let projectInfo: StoreOrderProjectInfo? + let shootingList: [StoreOrderShootingListItem] + + enum CodingKeys: String, CodingKey { + case projectInfo = "project_info" + case shootingList = "shooting_list" + } + + /// 宽松解码多景点旅拍信息。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + projectInfo = try container.decodeIfPresent(StoreOrderProjectInfo.self, forKey: .projectInfo) + shootingList = try container.decodeIfPresent([StoreOrderShootingListItem].self, forKey: .shootingList) ?? [] + } +} + +/// 订单项目配置实体,表示打卡点数量和单点素材数量。 +struct StoreOrderProjectInfo: Decodable, Equatable, Hashable { + let settleSpotNum: Int + let singleSpotMaterialNum: Int + let singleSpotPhotoNum: Int + let singleSpotVideoNum: Int + + enum CodingKeys: String, CodingKey { + case settleSpotNum = "settle_spot_num" + case singleSpotMaterialNum = "single_spot_material_num" + case singleSpotPhotoNum = "single_spot_photo_num" + case singleSpotVideoNum = "single_spot_video_num" + } + + /// 宽松解码项目配置字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + settleSpotNum = try container.decodeLossyInt(forKey: .settleSpotNum) ?? 0 + singleSpotMaterialNum = try container.decodeLossyInt(forKey: .singleSpotMaterialNum) ?? 0 + singleSpotPhotoNum = try container.decodeLossyInt(forKey: .singleSpotPhotoNum) ?? 0 + singleSpotVideoNum = try container.decodeLossyInt(forKey: .singleSpotVideoNum) ?? 0 + } +} + +/// 拍摄点状态实体,表示订单在某个打卡点的拍摄人员和完成情况。 +struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable { + var id: String { "\(scenicSpotId)-\(photogUid)" } + + let scenicSpotId: Int + let photogUid: Int + let scenicSpotName: String + let staffName: String + let photogName: String + let status: Int + let startAvg: Double + let start: Double + + enum CodingKeys: String, CodingKey { + case scenicSpotId = "scenic_spot_id" + case photogUid = "photog_uid" + case scenicSpotName = "scenic_spot_name" + case staffName = "staff_name" + case photogName = "photog_name" + case status + case startAvg = "start_avg" + case start + } + + /// 宽松解码拍摄点状态字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0 + photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0 + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + staffName = try container.decodeLossyString(forKey: .staffName) + photogName = try container.decodeLossyString(forKey: .photogName) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + startAvg = try container.decodeLossyDouble(forKey: .startAvg) ?? 0 + start = try container.decodeLossyDouble(forKey: .start) ?? 0 + } +} + +/// 订单拍摄信息响应实体,表示某个打卡点的评价、底片和成片。 +struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable { + let scenicSpotId: Int + let scenicSpotName: String + let orderType: Int + let orderTypeName: String + let orderComment: StoreOrderComment? + let materialList: [OrderMediaFile] + let completeList: [OrderMediaFile] + + enum CodingKeys: String, CodingKey { + case scenicSpotId = "scenic_spot_id" + case scenicSpotName = "scenic_spot_name" + case orderType = "order_type" + case orderTypeName = "order_type_name" + case orderComment = "order_comment" + case materialList = "material_list" + case completeList = "complete_list" + } + + /// 宽松解码拍摄信息详情。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0 + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0 + orderTypeName = try container.decodeLossyString(forKey: .orderTypeName) + orderComment = try container.decodeIfPresent(StoreOrderComment.self, forKey: .orderComment) + materialList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .materialList) ?? [] + completeList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .completeList) ?? [] + } +} + +/// 订单评价实体,表示拍摄、修图、景区、服务和相机评分。 +struct StoreOrderComment: Decodable, Equatable, Hashable { + let starShooting: Int + let starRetouching: Int + let starScenery: Int + let starService: Int + let starCamera: Int + let content: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case starShooting = "star_shooting" + case starRetouching = "star_retouching" + case starScenery = "star_scenery" + case starService = "star_service" + case starCamera = "star_camera" + case content + case createdAt = "created_at" + } + + /// 宽松解码评价字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + starShooting = try container.decodeLossyInt(forKey: .starShooting) ?? 0 + starRetouching = try container.decodeLossyInt(forKey: .starRetouching) ?? 0 + starScenery = try container.decodeLossyInt(forKey: .starScenery) ?? 0 + starService = try container.decodeLossyInt(forKey: .starService) ?? 0 + starCamera = try container.decodeLossyInt(forKey: .starCamera) ?? 0 + content = try container.decodeLossyString(forKey: .content) + createdAt = try container.decodeLossyString(forKey: .createdAt) + } +} + +/// 订单媒体文件实体,表示底片、成片或历史拍摄媒体。 +struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable { + var id: String { "\(fileUrl)-\(fileName)-\(uploadTime)" } + + let fileName: String + let fileUrl: String + let fileType: Int + let fileSize: Int64 + let coverUrl: String + let uploadTime: String + + var isVideo: Bool { + fileType == 1 + } + + var previewURLString: String { + coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl + } + + enum CodingKeys: String, CodingKey { + case fileName = "file_name" + case fileUrl = "file_url" + case fileType = "file_type" + case fileSize = "file_size" + case coverUrl = "cover_url" + case uploadTime = "upload_time" + } + + /// 宽松解码媒体文件字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + fileName = try container.decodeLossyString(forKey: .fileName) + fileUrl = try container.decodeLossyString(forKey: .fileUrl) + fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0 + fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0) + coverUrl = try container.decodeLossyString(forKey: .coverUrl) + uploadTime = try container.decodeLossyString(forKey: .uploadTime) + } +} + +/// 历史拍摄响应实体,表示订单多点位历史拍摄媒体。 +struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable { + let projectName: String + let projectType: Int + let projectTypeName: String + let photogSpotList: [PhotogSpotItem] + + enum CodingKeys: String, CodingKey { + case projectName = "project_name" + case projectType = "project_type" + case projectTypeName = "project_type_name" + case photogSpotList = "photog_spot_list" + } + + /// 宽松解码历史拍摄响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + projectName = try container.decodeLossyString(forKey: .projectName) + projectType = try container.decodeLossyInt(forKey: .projectType) ?? 0 + projectTypeName = try container.decodeLossyString(forKey: .projectTypeName) + photogSpotList = try container.decodeIfPresent([PhotogSpotItem].self, forKey: .photogSpotList) ?? [] + } +} + +/// 历史拍摄打卡点实体,表示某个摄影师在打卡点上传过的媒体。 +struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable { + var id: String { "\(scenicSpotId)-\(photogUid)" } + + let scenicSpotId: Int + let photogUid: Int + let scenicSpotName: String + let photogNickname: String + let photogName: String + let files: [OrderMediaFile] + + var photographerDisplayName: String { + let nickname = photogNickname.trimmingCharacters(in: .whitespacesAndNewlines) + return nickname.isEmpty ? photogName : nickname + } + + enum CodingKeys: String, CodingKey { + case scenicSpotId = "scenic_spot_id" + case photogUid = "photog_uid" + case scenicSpotName = "scenic_spot_name" + case photogNickname = "photog_nickname" + case photogName = "photog_name" + case files + } + + /// 宽松解码历史拍摄打卡点。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0 + photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0 + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + photogNickname = try container.decodeLossyString(forKey: .photogNickname) + photogName = try container.decodeLossyString(forKey: .photogName) + files = try container.decodeIfPresent([OrderMediaFile].self, forKey: .files) ?? [] + } +} + +/// 多点旅拍已核销打卡点实体,用于任务上传时选择目标打卡点。 +struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Hashable { + let id: Int + let name: String + + enum CodingKeys: String, CodingKey { + case id + case name + } + + /// 宽松解码已核销打卡点字段。 + 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 MultiTravelUploadMaterialRequest: Encodable, Equatable { + let orderNumber: String + let scenicSpotId: Int + let cloudFile: [MultiTravelCloudFileItem] + let uploadFile: [MultiTravelUploadFileItem] + + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case scenicSpotId = "scenic_spot_id" + case cloudFile = "cloud_file" + case uploadFile = "upload_file" + } +} + +/// 多点旅拍云盘素材提交项。 +struct MultiTravelCloudFileItem: Encodable, Equatable { + let fileId: Int + + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + } +} + +/// 多点旅拍本地上传素材提交项。 +struct MultiTravelUploadFileItem: Encodable, Equatable { + let fileName: String + let fileUrl: String + + enum CodingKeys: String, CodingKey { + case fileName = "file_name" + case fileUrl = "file_url" + } +} + +/// 订单详情展示适配实体,统一列表摘要和详情接口响应的读取方式。 +struct StoreOrderDetailDisplay: Equatable { + let orderNumber: String + let orderTypeLabel: String + let createdAt: String + let orderStatusName: String + let actualPayAmount: String + let actualRefundAmount: String + let payTypeName: String + let payTime: String + let completeTime: String + let userId: Int + let phone: String + let projectId: Int + let projectName: String + let remark: String + + /// 使用订单列表项构建兜底展示数据。 + init(item: OrderEntity) { + orderNumber = item.orderNumber + orderTypeLabel = item.orderTypeLabel + createdAt = item.createdAt + orderStatusName = item.orderStatusName + actualPayAmount = item.actualPayAmount + actualRefundAmount = item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount + payTypeName = item.payTypeName + payTime = item.payTime + completeTime = item.completeTime + userId = item.userId + phone = item.phone + projectId = item.projectId + projectName = item.projectName + remark = item.remark + } + + /// 使用详情接口响应构建完整展示数据。 + init(detail: StoreOrderDetailResponse) { + orderNumber = detail.orderNumber + orderTypeLabel = detail.orderTypeLabel + createdAt = detail.createdAt + orderStatusName = detail.orderStatusName + actualPayAmount = detail.actualPayAmount + actualRefundAmount = detail.actualRefundAmount + payTypeName = detail.payTypeName + payTime = detail.payTime + completeTime = detail.completeTime + userId = detail.userId + phone = detail.phone + projectId = detail.projectId + projectName = detail.projectName + remark = detail.remark + } +} + +/// 订单号解析器,负责从扫码内容或手动输入中提取可核销订单号。 +enum OrderNumberParser { + /// 从原始文本中解析订单号,兼容 URL、键值格式和纯订单号。 + static func parse(_ raw: String) -> String? { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + if let url = URL(string: text), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, + let value = queryItems.first(where: { $0.name.lowercased() == "order_number" })?.value, + !value.isEmpty { + return value + } + + let patterns = [ + "order[_-]?number[=:]([A-Za-z0-9_-]+)", + "([A-Za-z0-9_-]{8,})" + ] + for pattern in patterns { + if let match = firstCapture(in: text, pattern: pattern) { + return match + } + } + + if !text.contains(" "), text.count >= 6 { + return text + } + return nil + } + + /// 返回第一个正则捕获组。 + private static func firstCapture(in text: String, pattern: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return nil + } + let range = NSRange(text.startIndex.. 1, + let captureRange = Range(match.range(at: 1), in: text) else { + return nil + } + return String(text[captureRange]) + } +} + +private extension KeyedDecodingContainer { + /// 将任意常见 JSON 值宽松解码成字符串。 + 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 ? "1" : "0" + } + return "" + } + + /// 将 Int、Double、Bool 或数字字符串宽松解码成整数。 + 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) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? 1 : 0 + } + return nil + } + + /// 将 Bool、Int 或字符串宽松解码成布尔值。 + 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) { + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes": + return true + case "0", "false", "no": + return false + default: + return nil + } + } + return nil + } + + /// 将 Double、Int 或数字字符串宽松解码成浮点数。 + func decodeLossyDouble(forKey key: Key) throws -> Double? { + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} diff --git a/suixinkan_ios/Features/Orders/Orders.md b/suixinkan_ios/Features/Orders/Orders.md new file mode 100644 index 0000000..63c70bf --- /dev/null +++ b/suixinkan_ios/Features/Orders/Orders.md @@ -0,0 +1,49 @@ +# Orders 模块业务逻辑 + +## 模块职责 + +Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传和尾片入口闭环。 + +当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。 + +## 核心对象 + +- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。 +- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。 +- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。 +- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。 +- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。 +- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。 +- `OrderRefundViewModel`:管理普通订单退款入口判断、全额/部分退款金额校验和提交保护。 +- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。 +- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。 +- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。 +- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。 +- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。 +- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号。 + +## 数据流程 + +订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentRole` 读取角色 ID。景区管理员角色 `roleId == 53` 使用景区管理员订单接口,其他角色使用摄影师订单接口。 + +订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。 + +订单管理点击订单卡片进入订单详情。详情页优先用 `/api/app/store/order/detail` 按 `store_id + order_number` 拉取完整数据;缺少门店 ID 或接口失败时,保留列表卡片摘要作为兜底展示,并提示当前详情不完整。 + +核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。 + +押金订单入口由首页 `deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 进入。没有订单上下文时先展示押金订单页,用户可以手输订单号进入详情,也可以从列表进入详情。押金详情复用门店订单详情接口,按当前门店 ID 和订单号加载;拍摄点列表进入拍摄信息页,展示评价、底片和成片。 + +普通订单退款只在 `orderStatus == 18 || orderStatus == 30`、`orderType != 19` 且可退金额大于 0 时展示入口。全额退款使用可退金额,部分退款要求金额大于 0、最多两位小数且不能超过可退金额。押金退款要求填写退款原因,普通退款和押金退款都不保存表单数据,提交成功后刷新对应订单数据。 + +历史拍摄按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/shoot-history`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑。 + +任务上传仅对 `orderType == 19` 的多点旅拍订单展示。页面先按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list` 获取已核销打卡点,再支持选择云盘素材和本地图片/视频;本地素材先通过 `OSSUploadService.uploadTaskFile` 上传,成功后和云盘文件一起提交到 `/api/yf-handset-app/photog/order/multi-travel/upload-material`。 + +视频预告和尾片上传按旧工程现状统一进入 `OrderTrailerView`,该页只做订单尾片流程引导,并打开已迁移的 `AlbumTrailerEntryView` 完成相册预览上传,不新增独立订单尾片接口。 + +## 路由边界 + +首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。 + +未明确接口的订单后续能力继续进入占位页,避免入口崩溃。 diff --git a/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift new file mode 100644 index 0000000..6e30e7d --- /dev/null +++ b/suixinkan_ios/Features/Orders/ViewControllers/DepositOrderViewControllers.swift @@ -0,0 +1,360 @@ +// +// DepositOrderViewControllers.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 押金订单列表页,支持查询、分页、核销和退款。 +final class DepositOrderListViewController: UIViewController { + + private let viewModel = DepositOrderListViewModel() + private var orderNumberField = UITextField() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.dataSource = self + table.delegate = self + table.register(DepositOrderCell.self, forCellReuseIdentifier: DepositOrderCell.reuseID) + return table + }() + + override func viewDidLoad() { + super.viewDidLoad() + title = "押金订单" + view.backgroundColor = AppDesignUIKit.pageBackground + view.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + + viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + setupHeader() + Task { await reload(showLoading: true) } + } + + private func setupHeader() { + let header = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: 120)) + orderNumberField.placeholder = "请输入押金订单号" + orderNumberField.borderStyle = .roundedRect + orderNumberField.autocorrectionType = .no + orderNumberField.autocapitalizationType = .none + + let detailButton = makePrimaryButton(title: "查看订单详情") + detailButton.addTarget(self, action: #selector(openDetail), for: .touchUpInside) + + header.addSubview(orderNumberField) + header.addSubview(detailButton) + orderNumberField.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(16) + make.height.equalTo(44) + } + detailButton.snp.makeConstraints { make in + make.top.equalTo(orderNumberField.snp.bottom).offset(12) + make.leading.trailing.equalToSuperview().inset(16) + } + tableView.tableHeaderView = header + } + + @objc private func openDetail() { + let text = orderNumberField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !text.isEmpty else { return } + HomeMenuRouting.pushOrders(.depositDetail(orderNumber: text), from: self) + } + + private func reload(showLoading: Bool) async { + if showLoading { + await appServices.globalLoading.withLoading { + await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: true) + } + } else { + await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: true) + } + if let message = viewModel.errorMessage { showToast(message) } + } + + private func writeOff(_ item: DepositOrderListItem) { + let alert = UIAlertController(title: "确认核销该押金订单?", message: item.orderNumber, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确认核销", style: .default) { [weak self] _ in + Task { + guard let self else { return } + let success = await self.viewModel.writeOff(api: self.appServices.ordersAPI, scenicId: self.appServices.accountContext.currentScenic?.id, orderNumber: item.orderNumber) + if success { self.showToast("核销成功") } + else if let message = self.viewModel.errorMessage { self.showToast(message) } + } + }) + present(alert, animated: true) + } + + private func refund(_ item: DepositOrderListItem) { + let alert = UIAlertController(title: "申请退款", message: "请填写退款原因", preferredStyle: .alert) + alert.addTextField { $0.placeholder = "退款原因" } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "提交", style: .default) { [weak self] _ in + Task { + guard let self else { return } + let reason = alert.textFields?.first?.text ?? "" + let success = await self.viewModel.refund(api: self.appServices.ordersAPI, scenicId: self.appServices.accountContext.currentScenic?.id, orderNumber: item.orderNumber, reason: reason) + if success { self.showToast("退款申请已提交") } + else if let message = self.viewModel.errorMessage { self.showToast(message) } + } + }) + present(alert, animated: true) + } +} + +extension DepositOrderListViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + max(viewModel.orders.count, 1) + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + "押金订单列表 \(viewModel.orders.count)/\(viewModel.total)" + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard !viewModel.orders.isEmpty else { + let cell = UITableViewCell() + cell.textLabel?.text = viewModel.loading ? "加载中..." : "暂无押金订单" + cell.selectionStyle = .none + return cell + } + let cell = tableView.dequeueReusableCell(withIdentifier: DepositOrderCell.reuseID, for: indexPath) as! DepositOrderCell + let item = viewModel.orders[indexPath.row] + cell.configure(item: item, isOperating: viewModel.operatingOrderNumber == item.orderNumber) + cell.onDetail = { [weak self] in + self.flatMap { HomeMenuRouting.pushOrders(.depositDetail(orderNumber: item.orderNumber), from: $0) } + } + cell.onWriteOff = { [weak self] in self?.writeOff(item) } + cell.onRefund = { [weak self] in self?.refund(item) } + return cell + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard indexPath.row == viewModel.orders.count - 1, viewModel.hasMore else { return } + Task { + await viewModel.reload(api: appServices.ordersAPI, scenicId: appServices.accountContext.currentScenic?.id, reset: false) + } + } +} + +private final class DepositOrderCell: UITableViewCell { + static let reuseID = "DepositOrderCell" + var onDetail: (() -> Void)? + var onWriteOff: (() -> Void)? + var onRefund: (() -> Void)? + + private let titleLabel = UILabel() + private let statusLabel = UILabel() + private let amountLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) + statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + amountLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold) + amountLabel.textColor = AppDesignUIKit.primary + + let detailButton = UIButton(type: .system) + detailButton.setTitle("详情", for: .normal) + detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside) + let writeOffButton = UIButton(type: .system) + writeOffButton.setTitle("核销", for: .normal) + writeOffButton.addTarget(self, action: #selector(writeOffTapped), for: .touchUpInside) + let refundButton = UIButton(type: .system) + refundButton.setTitle("退款", for: .normal) + refundButton.addTarget(self, action: #selector(refundTapped), for: .touchUpInside) + + let buttonRow = UIStackView(arrangedSubviews: [detailButton, writeOffButton, refundButton]) + buttonRow.axis = .horizontal + buttonRow.distribution = .fillEqually + + let stack = UIStackView(arrangedSubviews: [titleLabel, amountLabel, buttonRow]) + stack.axis = .vertical + stack.spacing = 8 + contentView.addSubview(stack) + contentView.addSubview(statusLabel) + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) } + statusLabel.snp.makeConstraints { make in make.trailing.top.equalToSuperview().inset(12) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(item: DepositOrderListItem, isOperating: Bool) { + titleLabel.text = item.orderNumber + statusLabel.text = isOperating ? "处理中" : item.statusName + amountLabel.text = "¥\(item.amount.isEmpty ? "0.00" : item.amount)" + } + + @objc private func detailTapped() { onDetail?() } + @objc private func writeOffTapped() { onWriteOff?() } + @objc private func refundTapped() { onRefund?() } +} + +/// 押金订单详情页。 +final class DepositOrderDetailViewController: UIViewController { + + private let orderNumber: String + private let viewModel = DepositOrderDetailViewModel() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.dataSource = self + table.backgroundColor = AppDesignUIKit.pageBackground + return table + }() + + init(orderNumber: String) { + self.orderNumber = orderNumber + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + title = "押金订单详情" + view.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + Task { await loadDetail() } + } + + private func loadDetail() async { + await appServices.globalLoading.withLoading { + await viewModel.load( + api: appServices.ordersAPI, + storeId: appServices.accountContext.currentStore?.id, + orderNumber: orderNumber + ) + } + tableView.reloadData() + if let message = viewModel.errorMessage { showToast(message) } + } +} + +extension DepositOrderDetailViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.detail == nil ? 1 : 8 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + guard let detail = viewModel.detail else { + cell.textLabel?.text = viewModel.errorMessage ?? (viewModel.loading ? "加载中..." : "暂无详情") + return cell + } + let rows: [(String, String)] = [ + ("订单号", detail.orderNumber), + ("状态", detail.orderStatusName), + ("类型", detail.orderTypeLabel), + ("付款金额", detail.actualPayAmount), + ("手机号", detail.phone), + ("项目", detail.projectName), + ("创建时间", detail.createdAt), + ("备注", detail.remark) + ] + cell.textLabel?.text = rows[indexPath.row].0 + cell.detailTextLabel?.text = rows[indexPath.row].1 + return cell + } +} + +/// 押金拍摄信息页。 +final class DepositOrderShootingInfoViewController: UIViewController { + + private let orderNumber: String + private let scenicSpotId: Int + private let photogUid: Int + private let viewModel = DepositOrderShootingInfoViewModel() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.dataSource = self + return table + }() + + init(orderNumber: String, scenicSpotId: Int, photogUid: Int) { + self.orderNumber = orderNumber + self.scenicSpotId = scenicSpotId + self.photogUid = photogUid + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + title = "押金拍摄信息" + view.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + Task { await loadDetail() } + } + + private func loadDetail() async { + await appServices.globalLoading.withLoading { + await viewModel.load( + api: appServices.ordersAPI, + storeId: appServices.accountContext.currentStore?.id, + orderNumber: orderNumber, + scenicSpotId: scenicSpotId, + photogUid: photogUid + ) + } + tableView.reloadData() + if let message = viewModel.errorMessage { showToast(message) } + } +} + +extension DepositOrderShootingInfoViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + viewModel.detail == nil ? 1 : 3 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let detail = viewModel.detail else { return 1 } + switch section { + case 0: return 2 + case 1: return detail.materialList.count + default: return detail.completeList.count + } + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard viewModel.detail != nil else { return nil } + switch section { + case 1: return "素材" + case 2: return "成片" + default: return nil + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let detail = viewModel.detail else { + let cell = UITableViewCell() + cell.textLabel?.text = viewModel.errorMessage ?? "加载中..." + return cell + } + if indexPath.section == 0 { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + if indexPath.row == 0 { + cell.textLabel?.text = "打卡点" + cell.detailTextLabel?.text = detail.scenicSpotName + } else { + cell.textLabel?.text = "拍摄评分" + cell.detailTextLabel?.text = detail.orderComment.map { "\($0.starShooting) 星" } ?? "--" + } + return cell + } + let files = indexPath.section == 1 ? detail.materialList : detail.completeList + let media = files[indexPath.row] + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + cell.textLabel?.text = media.fileName + cell.detailTextLabel?.text = media.fileUrl + return cell + } +} diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift new file mode 100644 index 0000000..b14438c --- /dev/null +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderCodeScannerViewController.swift @@ -0,0 +1,301 @@ +// +// OrderCodeScannerViewController.swift +// suixinkan +// + +import AVFoundation +import SnapKit +import UIKit + +/// 订单扫码错误实体,描述相机不可用、权限拒绝和元数据异常。 +enum OrderScannerError: LocalizedError { + case cameraUnavailable + case permissionDenied + case invalidMetadata + + var errorDescription: String? { + switch self { + case .cameraUnavailable: + "当前设备不可用相机,无法扫码。" + case .permissionDenied: + "未开启相机权限,请在系统设置中允许后重试。" + case .invalidMetadata: + "未识别到有效条码,请重试。" + } + } +} + +/// 订单扫码页,包装 AVFoundation 扫码控制器并提供关闭与结果回调。 +final class OrderCodeScannerViewController: UIViewController { + + var onScanResult: ((Result) -> Void)? + + private let scannerController = OrderScannerCaptureViewController() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + title = "扫码核销" + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "关闭", style: .plain, target: self, action: #selector(closeTapped)) + + #if targetEnvironment(simulator) + showUnavailablePlaceholder() + #else + if AppUITestLaunchState.isRunningUITests || AVCaptureDevice.default(for: .video) == nil { + showUnavailablePlaceholder() + } else { + embedScanner() + } + #endif + } + + private func embedScanner() { + scannerController.onScanResult = { [weak self] result in + self?.onScanResult?(result) + } + addChild(scannerController) + view.addSubview(scannerController.view) + scannerController.view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + scannerController.didMove(toParent: self) + + let hintLabel = UILabel() + hintLabel.text = "请将二维码/条码放入取景框" + hintLabel.font = .systemFont(ofSize: AppMetrics.FontSize.footnote, weight: .medium) + hintLabel.textColor = .white + hintLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55) + hintLabel.layer.cornerRadius = 16 + hintLabel.clipsToBounds = true + hintLabel.textAlignment = .center + view.addSubview(hintLabel) + hintLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.xxLarge) + make.height.equalTo(32) + make.width.greaterThanOrEqualTo(220) + } + } + + private func showUnavailablePlaceholder() { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = AppMetrics.Spacing.small + stack.alignment = .center + + let icon = UIImageView(image: UIImage(systemName: "camera.viewfinder")) + icon.tintColor = .white + icon.contentMode = .scaleAspectFit + + let titleLabel = UILabel() + titleLabel.text = "当前环境不可用相机" + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold) + titleLabel.textColor = .white + + let messageLabel = UILabel() + messageLabel.text = "请在真机上使用扫码核销" + messageLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + messageLabel.textColor = UIColor.white.withAlphaComponent(0.72) + + stack.addArrangedSubview(icon) + stack.addArrangedSubview(titleLabel) + stack.addArrangedSubview(messageLabel) + view.addSubview(stack) + icon.snp.makeConstraints { make in + make.width.height.equalTo(48) + } + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + @objc private func closeTapped() { + dismiss(animated: true) + } +} + +/// AVFoundation 扫码控制器,管理相机权限、预览层、识别类型和手电筒。 +final class OrderScannerCaptureViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + + var onScanResult: ((Result) -> Void)? + + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + private var videoDevice: AVCaptureDevice? + private var hasFinishedScan = false + + private let scanFrameView: UIView = { + let view = UIView() + view.layer.borderColor = UIColor.systemBlue.cgColor + view.layer.borderWidth = 2 + view.layer.cornerRadius = 12 + view.backgroundColor = .clear + return view + }() + + private lazy var torchButton: UIButton = { + let button = Self.makeScannerButton(title: "手电筒") + button.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside) + return button + }() + + private lazy var retryButton: UIButton = { + let button = Self.makeScannerButton(title: "重新扫描") + button.addTarget(self, action: #selector(restartScan), for: .touchUpInside) + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + setupControls() + checkPermissionAndStart() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.layer.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + updateTorch(enabled: false) + stopSession() + } + + private func checkPermissionAndStart() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + startSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + DispatchQueue.main.async { + guard let self else { return } + if granted { + self.startSession() + } else { + self.onScanResult?(.failure(OrderScannerError.permissionDenied)) + } + } + } + case .denied, .restricted: + onScanResult?(.failure(OrderScannerError.permissionDenied)) + @unknown default: + onScanResult?(.failure(OrderScannerError.permissionDenied)) + } + } + + private func startSession() { + hasFinishedScan = false + guard !session.isRunning else { return } + do { + try configureSession() + } catch { + onScanResult?(.failure(error)) + return + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.session.startRunning() + } + } + + private func stopSession() { + guard session.isRunning else { return } + session.stopRunning() + } + + private static func makeScannerButton(title: String) -> UIButton { + var config = UIButton.Configuration.filled() + config.title = title + config.baseForegroundColor = .white + config.baseBackgroundColor = UIColor.black.withAlphaComponent(0.42) + config.cornerStyle = .capsule + return UIButton(configuration: config) + } + + private func configureSession() throws { + if previewLayer != nil { return } + guard let videoDevice = AVCaptureDevice.default(for: .video) else { + throw OrderScannerError.cameraUnavailable + } + self.videoDevice = videoDevice + + let videoInput = try AVCaptureDeviceInput(device: videoDevice) + guard session.canAddInput(videoInput) else { throw OrderScannerError.cameraUnavailable } + session.addInput(videoInput) + + let metadataOutput = AVCaptureMetadataOutput() + guard session.canAddOutput(metadataOutput) else { throw OrderScannerError.invalidMetadata } + session.addOutput(metadataOutput) + + metadataOutput.setMetadataObjectsDelegate(self, queue: .main) + let desiredTypes: [AVMetadataObject.ObjectType] = [.qr, .ean8, .ean13, .pdf417, .code39, .code93, .code128, .dataMatrix, .aztec, .itf14, .upce] + let supportedTypes = desiredTypes.filter { metadataOutput.availableMetadataObjectTypes.contains($0) } + guard !supportedTypes.isEmpty else { throw OrderScannerError.invalidMetadata } + metadataOutput.metadataObjectTypes = supportedTypes + + let preview = AVCaptureVideoPreviewLayer(session: session) + preview.videoGravity = .resizeAspectFill + preview.frame = view.layer.bounds + view.layer.insertSublayer(preview, at: 0) + previewLayer = preview + torchButton.isEnabled = videoDevice.hasTorch + } + + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + guard !hasFinishedScan else { return } + guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let code = metadataObject.stringValue, + !code.isEmpty else { return } + hasFinishedScan = true + stopSession() + UINotificationFeedbackGenerator().notificationOccurred(.success) + onScanResult?(.success(code)) + } + + private func setupControls() { + view.addSubview(scanFrameView) + view.addSubview(torchButton) + view.addSubview(retryButton) + + scanFrameView.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-40) + make.width.height.equalTo(240) + } + torchButton.snp.makeConstraints { make in + make.leading.equalTo(view.safeAreaLayoutGuide).inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18) + } + retryButton.snp.makeConstraints { make in + make.trailing.equalTo(view.safeAreaLayoutGuide).inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(18) + } + } + + @objc private func toggleTorch() { + updateTorch(enabled: !(videoDevice?.isTorchActive ?? false)) + } + + private func updateTorch(enabled: Bool) { + guard let device = videoDevice, device.hasTorch else { return } + do { + try device.lockForConfiguration() + if enabled { + try device.setTorchModeOn(level: AVCaptureDevice.maxAvailableTorchLevel) + } else { + device.torchMode = .off + } + device.unlockForConfiguration() + torchButton.configuration?.title = enabled ? "关闭手电" : "手电筒" + } catch { + torchButton.configuration?.title = "手电筒不可用" + } + } + + @objc private func restartScan() { + hasFinishedScan = false + updateTorch(enabled: false) + startSession() + } +} diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift new file mode 100644 index 0000000..ca8dce9 --- /dev/null +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderDetailViewControllers.swift @@ -0,0 +1,252 @@ +// +// OrderDetailViewControllers.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。 +final class StoreOrderDetailViewController: UIViewController { + + private let item: OrderEntity + private let viewModel: OrderDetailViewModel + private let refundViewModel = OrderRefundViewModel() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.dataSource = self + table.delegate = self + table.backgroundColor = AppDesignUIKit.pageBackground + return table + }() + + init(item: OrderEntity) { + self.item = item + self.viewModel = OrderDetailViewModel(item: item) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + title = "订单详情" + view.backgroundColor = AppDesignUIKit.pageBackground + view.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + + viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + Task { await loadDetail() } + } + + private func loadDetail() async { + await appServices.globalLoading.withLoading(message: "加载详情中...") { + await viewModel.load(api: appServices.ordersAPI, fallbackStoreId: appServices.accountContext.currentStore?.id) + } + if let error = viewModel.errorMessage { + showToast(error) + } + } + + private func copyOrderNumber() { + UIPasteboard.general.string = viewModel.display.orderNumber + showToast("订单号已复制") + } + + private func presentRefund() { + refundViewModel.begin(item: item) + let alert = UIAlertController(title: "订单退款", message: "可退 ¥\(refundViewModel.availableAmountText(for: item))", preferredStyle: .alert) + alert.addTextField { $0.placeholder = "退款原因" } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "提交", style: .default) { [weak self] _ in + guard let self else { return } + self.refundViewModel.reason = alert.textFields?.first?.text ?? "" + Task { + let success = await self.refundViewModel.submit(api: self.appServices.ordersAPI, item: self.item) + if success { + self.showToast("退款申请已提交") + await self.viewModel.load(api: self.appServices.ordersAPI, fallbackStoreId: self.appServices.accountContext.currentStore?.id, forceReload: true) + } else if let message = self.refundViewModel.errorMessage { + self.showToast(message) + } + } + }) + present(alert, animated: true) + } +} + +extension StoreOrderDetailViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { 6 } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch section { + case 0: return viewModel.contextMessage == nil ? 0 : 1 + case 1: return 7 + case 2: return 4 + case 3: return 4 + case 4: return viewModel.shootingList.count + case 5: return 5 + default: return 0 + } + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch section { + case 1: "订单信息" + case 2: "支付信息" + case 3: "客户与项目" + case 4 where !viewModel.shootingList.isEmpty: "拍摄点" + case 5: "后续功能" + default: nil + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + let display = viewModel.display + + switch indexPath.section { + case 0: + cell.textLabel?.text = viewModel.contextMessage + cell.textLabel?.numberOfLines = 0 + case 1: + let rows = ["订单号", "状态", "类型", "创建时间", "付款时间", "完成时间", "复制订单号"] + cell.textLabel?.text = rows[indexPath.row] + switch indexPath.row { + case 0: cell.detailTextLabel?.text = display.orderNumber + case 1: cell.detailTextLabel?.text = display.orderStatusName + case 2: cell.detailTextLabel?.text = display.orderTypeLabel + case 3: cell.detailTextLabel?.text = display.createdAt + case 4: cell.detailTextLabel?.text = displayPayTime + case 5: cell.detailTextLabel?.text = display.completeTime + default: + cell.textLabel?.textColor = AppDesignUIKit.primary + cell.selectionStyle = .default + } + case 2: + let rows = ["付款金额", "退款金额", "付款方式", "用户 UID"] + cell.textLabel?.text = rows[indexPath.row] + switch indexPath.row { + case 0: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualPayAmount))" + case 1: cell.detailTextLabel?.text = "¥\(emptyToZero(display.actualRefundAmount))" + case 2: cell.detailTextLabel?.text = display.payTypeName + default: cell.detailTextLabel?.text = "\(display.userId)" + } + case 3: + let rows = ["手机号", "关联项目", "项目 ID", "备注"] + cell.textLabel?.text = rows[indexPath.row] + switch indexPath.row { + case 0: cell.detailTextLabel?.text = display.phone + case 1: cell.detailTextLabel?.text = display.projectName + case 2: cell.detailTextLabel?.text = "\(display.projectId)" + default: cell.detailTextLabel?.text = display.remark + } + case 4: + let shooting = viewModel.shootingList[indexPath.row] + cell.textLabel?.text = shooting.scenicSpotName + cell.detailTextLabel?.text = shooting.staffName.isEmpty ? "状态 \(shooting.status)" : shooting.staffName + case 5: + let actions = ["历史拍摄", "任务上传", "视频预告", "尾片上传", "退款"] + cell.textLabel?.text = actions[indexPath.row] + cell.accessoryType = .disclosureIndicator + cell.selectionStyle = .default + if indexPath.row == 4, !refundViewModel.canRefund(item) { + cell.isUserInteractionEnabled = false + cell.textLabel?.textColor = AppDesignUIKit.textSecondary + } + default: break + } + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if indexPath.section == 1, indexPath.row == 6 { copyOrderNumber(); return } + guard indexPath.section == 5 else { return } + let orderNumber = viewModel.display.orderNumber + switch indexPath.row { + case 0: + HomeMenuRouting.pushOrders(.historicalShooting(orderNumber: orderNumber), from: self) + case 1 where item.orderType == 19: + HomeMenuRouting.pushOrders(.multiTravelTaskUpload(orderNumber: orderNumber), from: self) + case 2: + HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "视频预告"), from: self) + case 3: + HomeMenuRouting.pushOrders(.orderTrailer(orderNumber: orderNumber, title: "尾片上传"), from: self) + case 4: + presentRefund() + default: break + } + } + + private var displayPayTime: String { + let payTime = viewModel.display.payTime + if payTime.isEmpty || payTime == "0" || payTime.hasPrefix("1970") { return item.depositPayTime } + return payTime + } + + private func emptyToZero(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value + } +} + +/// 核销订单详情页,展示核销列表项摘要信息。 +final class WriteOffOrderDetailViewController: UIViewController { + + private let item: WriteOffOrderItem + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.dataSource = self + table.backgroundColor = AppDesignUIKit.pageBackground + return table + }() + + init(item: WriteOffOrderItem) { + self.item = item + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + title = "核销详情" + view.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + } +} + +extension WriteOffOrderDetailViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 6 } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + switch indexPath.row { + case 0: + cell.textLabel?.text = "订单号" + cell.detailTextLabel?.text = item.orderNumber + case 1: + cell.textLabel?.text = "项目" + cell.detailTextLabel?.text = item.projectName + case 2: + cell.textLabel?.text = "手机号" + cell.detailTextLabel?.text = item.userPhone + case 3: + cell.textLabel?.text = "金额" + cell.detailTextLabel?.text = "¥\(item.orderAmount)" + case 4: + cell.textLabel?.text = "状态" + cell.detailTextLabel?.text = item.orderStatusName + default: + cell.textLabel?.text = "核销时间" + cell.detailTextLabel?.text = item.orderVerificationTime + } + return cell + } +} diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift new file mode 100644 index 0000000..40e7157 --- /dev/null +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrderTailUploadViewControllers.swift @@ -0,0 +1,268 @@ +// +// OrderTailUploadViewControllers.swift +// suixinkan +// + +import PhotosUI +import SnapKit +import UIKit +import UniformTypeIdentifiers + +/// 多点旅拍任务上传页,负责选择打卡点和素材后提交到订单接口。 +final class MultiTravelTaskUploadViewController: UIViewController { + + private let viewModel: MultiTravelTaskUploadViewModel + + private let orderField = UITextField() + private let spotButton = UIButton(type: .system) + private let cloudListLabel = UILabel() + private let localListLabel = UILabel() + private let submitButton = UIButton(type: .system) + + init(initialOrderNumber: String) { + self.viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + title = "任务上传" + view.backgroundColor = UIColor(hex: 0xF5F7FA) + setupForm() + bindViewModel() + Task { await viewModel.loadSpots(api: appServices.ordersAPI) } + } + + private func bindViewModel() { + viewModel.onChange = { [weak self] in + self?.applyViewModel() + } + } + + private func setupForm() { + orderField.borderStyle = .roundedRect + orderField.placeholder = "关联订单号" + orderField.autocorrectionType = .no + orderField.autocapitalizationType = .none + orderField.text = viewModel.orderNumber + orderField.addTarget(self, action: #selector(orderChanged), for: .editingChanged) + + spotButton.setTitle("选择打卡点", for: .normal) + spotButton.addTarget(self, action: #selector(selectSpot), for: .touchUpInside) + + cloudListLabel.numberOfLines = 0 + cloudListLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + localListLabel.numberOfLines = 0 + localListLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + + submitButton.configuration = .filled() + submitButton.configuration?.title = "保存任务素材" + submitButton.configuration?.baseBackgroundColor = AppDesignUIKit.primary + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + + let refreshButton = UIButton(type: .system) + refreshButton.setTitle("刷新打卡点", for: .normal) + refreshButton.addTarget(self, action: #selector(refreshSpots), for: .touchUpInside) + + let pickLocalButton = UIButton(type: .system) + pickLocalButton.setTitle("选择图片/视频", for: .normal) + pickLocalButton.addTarget(self, action: #selector(pickLocalFiles), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [ + labeledRow("订单号", orderField), + refreshButton, + spotButton, + labeledRow("云盘素材", cloudListLabel), + pickLocalButton, + labeledRow("本地素材", localListLabel), + submitButton + ]) + stack.axis = .vertical + stack.spacing = 12 + view.addSubview(stack) + stack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(16) + make.leading.trailing.equalToSuperview().inset(16) + } + applyViewModel() + } + + private func labeledRow(_ title: String, _ content: UIView) -> UIStackView { + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold) + let row = UIStackView(arrangedSubviews: [label, content]) + row.axis = .vertical + row.spacing = 8 + return row + } + + private func applyViewModel() { + orderField.text = viewModel.orderNumber + spotButton.setTitle(viewModel.isLoadingSpots ? "加载中..." : viewModel.selectedSpotName, for: .normal) + spotButton.isEnabled = !viewModel.isLoadingSpots + + if viewModel.selectedCloudFiles.isEmpty { + cloudListLabel.text = "未选择云盘文件" + } else { + cloudListLabel.text = viewModel.selectedCloudFiles.map(\.fileName).joined(separator: "\n") + } + + if viewModel.selectedLocalFiles.isEmpty { + localListLabel.text = "未选择本地文件" + } else { + localListLabel.text = viewModel.selectedLocalFiles.map { file in + "\(file.fileName) \(file.statusText)" + }.joined(separator: "\n") + } + + submitButton.isEnabled = viewModel.canSubmit + submitButton.configuration?.title = viewModel.isSubmitting ? "保存中..." : "保存任务素材" + } + + @objc private func orderChanged() { + viewModel.orderNumber = orderField.text ?? "" + } + + @objc private func refreshSpots() { + Task { await viewModel.loadSpots(api: appServices.ordersAPI) } + } + + @objc private func selectSpot() { + guard !viewModel.spots.isEmpty else { return } + let sheet = UIAlertController(title: "选择打卡点", message: nil, preferredStyle: .actionSheet) + for spot in viewModel.spots { + let title = spot.name.isEmpty ? "打卡点 \(spot.id)" : spot.name + sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in + self?.viewModel.selectSpot(id: spot.id) + }) + } + sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(sheet, animated: true) + } + + @objc private func pickLocalFiles() { + var config = PHPickerConfiguration() + config.selectionLimit = 9 + config.filter = .any(of: [.images, .videos]) + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + present(picker, animated: true) + } + + @objc private func submitTapped() { + Task { + let success = await viewModel.submit( + api: appServices.ordersAPI, + uploadService: appServices.ossUploadService, + scenicId: appServices.accountContext.currentScenic?.id + ) + if success { + showToast("提交成功") + navigationController?.popViewController(animated: true) + } else if let message = viewModel.errorMessage { + showToast(message) + } + applyViewModel() + } + } +} + +extension MultiTravelTaskUploadViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard !results.isEmpty else { return } + for result in results { + let provider = result.itemProvider + if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in + guard let self, let url else { return } + let data = (try? Data(contentsOf: url)) ?? Data() + DispatchQueue.main.async { + self.viewModel.addLocalFile(data: data, fileName: url.lastPathComponent) + self.applyViewModel() + } + } + } else if provider.canLoadObject(ofClass: UIImage.self) { + provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in + guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return } + DispatchQueue.main.async { + self.viewModel.addLocalFile(data: data, fileName: "upload_\(Int(Date().timeIntervalSince1970)).jpg") + self.applyViewModel() + } + } + } + } + } +} + +/// 历史拍摄信息页。 +final class HistoricalShootingInfoViewController: UIViewController { + + private let orderNumber: String + private let viewModel = HistoricalShootingInfoViewModel() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.dataSource = self + table.delegate = self + return table + }() + + init(orderNumber: String) { + self.orderNumber = orderNumber + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + title = "历史拍摄" + view.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + Task { await loadData() } + } + + private func loadData() async { + await appServices.globalLoading.withLoading { + await viewModel.load(api: appServices.ordersAPI, orderNumber: orderNumber) + } + if let message = viewModel.errorMessage { showToast(message) } + } +} + +extension HistoricalShootingInfoViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { 2 } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? 2 : max(viewModel.spots.count, 1) + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 1 ? "拍摄点位" : nil + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.selectionStyle = .none + if indexPath.section == 0 { + cell.textLabel?.text = indexPath.row == 0 ? "项目" : "项目类型" + cell.detailTextLabel?.text = indexPath.row == 0 ? viewModel.projectName : viewModel.projectTypeName + return cell + } + guard !viewModel.spots.isEmpty else { + cell.textLabel?.text = viewModel.errorMessage ?? "暂无历史拍摄" + return cell + } + let spot = viewModel.spots[indexPath.row] + cell.textLabel?.text = spot.scenicSpotName + cell.detailTextLabel?.text = "\(spot.files.count) 个文件 · \(spot.photographerDisplayName)" + return cell + } +} diff --git a/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift b/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift new file mode 100644 index 0000000..36a7059 --- /dev/null +++ b/suixinkan_ios/Features/Orders/ViewControllers/OrdersViewController.swift @@ -0,0 +1,604 @@ +// +// OrdersViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 订单 Tab 根页面,展示订单管理和核销订单两个子入口及对应列表。 +final class OrdersViewController: UIViewController { + + private let viewModel = OrdersViewModel() + private var manualOrderNumber = "" + private var scanHintMessage: String? + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .grouped) + table.backgroundColor = AppDesignUIKit.pageBackground + table.separatorStyle = .none + table.dataSource = self + table.delegate = self + table.register(OrderEntityCell.self, forCellReuseIdentifier: OrderEntityCell.reuseID) + table.register(WriteOffOrderCell.self, forCellReuseIdentifier: WriteOffOrderCell.reuseID) + table.register(OrdersHeaderCell.self, forCellReuseIdentifier: OrdersHeaderCell.reuseID) + table.register(OrdersFilterCell.self, forCellReuseIdentifier: OrdersFilterCell.reuseID) + table.register(OrdersWriteOffActionCell.self, forCellReuseIdentifier: OrdersWriteOffActionCell.reuseID) + return table + }() + + private lazy var refreshControl = UIRefreshControl() + + override func viewDidLoad() { + super.viewDidLoad() + title = "订单" + view.backgroundColor = AppDesignUIKit.pageBackground + + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) + tableView.refreshControl = refreshControl + + viewModel.onChange = { [weak self] in + self?.tableView.reloadData() + } + + appServices.appRouter.onChange = { [weak self] in + guard let self else { return } + if self.viewModel.selectedEntry != self.appServices.appRouter.selectedOrdersEntry { + self.viewModel.selectedEntry = self.appServices.appRouter.selectedOrdersEntry + Task { await self.reload(showLoading: true) } + } + Task { await self.consumePendingScanCodeIfNeeded() } + } + appServices.accountContext.onChange = { [weak self] in + Task { await self?.reload(showLoading: true) } + } + + viewModel.selectedEntry = appServices.appRouter.selectedOrdersEntry + Task { + await reload(showLoading: true) + await consumePendingScanCodeIfNeeded() + } + } + + @objc private func refreshPulled() { + Task { + await reload(showLoading: false) + refreshControl.endRefreshing() + } + } + + private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id } + private var currentStoreId: Int? { appServices.accountContext.currentStore?.id } + private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } + + private func reload(showLoading: Bool) async { + do { + try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载订单...") { + try await self.viewModel.reload( + api: self.appServices.ordersAPI, + scenicId: self.currentScenicId, + storeId: self.currentStoreId, + roleId: self.currentRoleId, + showLoading: false + ) + } + } catch { + showToast(error.localizedDescription) + } + } + + private func loadMore() async { + do { + if viewModel.selectedEntry == .storeOrders { + try await viewModel.loadMoreStoreOrders( + api: appServices.ordersAPI, + scenicId: currentScenicId, + roleId: currentRoleId + ) + } else { + try await viewModel.loadMoreWriteOffOrders( + api: appServices.ordersAPI, + scenicId: currentScenicId, + storeId: currentStoreId + ) + } + } catch { + showToast(error.localizedDescription) + } + } + + private func switchEntry(_ entry: OrdersEntry) { + guard viewModel.selectedEntry != entry else { return } + viewModel.selectedEntry = entry + appServices.appRouter.selectedOrdersEntry = entry + Task { await reload(showLoading: true) } + } + + private func verify(orderNumber: String) async { + guard let scenicId = currentScenicId else { return } + do { + try await appServices.globalLoading.withLoading(message: "核销中...") { + try await self.viewModel.verify( + api: self.appServices.ordersAPI, + scenicId: scenicId, + storeId: self.currentStoreId, + orderNumber: orderNumber + ) + } + showToast("核销成功") + } catch { + showToast(error.localizedDescription) + } + } + + private func consumePendingScanCodeIfNeeded() async { + guard viewModel.selectedEntry == .verificationOrders, + let code = appServices.appRouter.consumePendingOrderScanCode() else { return } + handleScanResult(code) + } + + private func handleScanResult(_ rawCode: String) { + guard let parsed = viewModel.matchedWriteOffOrder(for: rawCode) else { + showToast("未识别到有效订单号") + return + } + if parsed.matched != nil { + confirmVerify(orderNumber: parsed.orderNumber) + } else { + scanHintMessage = "扫码成功,当前列表未找到该订单" + tableView.reloadData() + } + } + + private func confirmVerify(orderNumber: String) { + let alert = UIAlertController(title: "确认核销该订单?", message: orderNumber, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "确认核销", style: .default) { [weak self] _ in + Task { await self?.verify(orderNumber: orderNumber) } + }) + present(alert, animated: true) + } + + private func presentScanner() { + let scanner = OrderCodeScannerViewController() + scanner.onScanResult = { [weak self, weak scanner] result in + scanner?.dismiss(animated: true) + guard let self else { return } + switch result { + case .success(let code): + self.handleScanResult(code) + case .failure(let error): + self.showToast(error.localizedDescription) + } + } + let nav = UINavigationController(rootViewController: scanner) + nav.modalPresentationStyle = .fullScreen + present(nav, animated: true) + } + + private func presentDateFilter() { + let alert = UIAlertController(title: "时间筛选", message: "选择开始和结束日期", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "清除筛选", style: .destructive) { [weak self] _ in + self?.viewModel.filterStartDate = nil + self?.viewModel.filterEndDate = nil + Task { await self?.reload(showLoading: true) } + }) + alert.addAction(UIAlertAction(title: "近7天", style: .default) { [weak self] _ in + let end = Date() + let start = Calendar.current.date(byAdding: .day, value: -6, to: end) ?? end + self?.viewModel.filterStartDate = start + self?.viewModel.filterEndDate = end + Task { await self?.reload(showLoading: true) } + }) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(alert, animated: true) + } + + private func presentStatusFilter() { + let sheet = UIAlertController(title: "订单状态", message: nil, preferredStyle: .actionSheet) + for filter in OrderFilters.statusFilters { + sheet.addAction(UIAlertAction(title: filter.title, style: .default) { [weak self] _ in + self?.viewModel.selectedStatus = filter.id + Task { await self?.reload(showLoading: true) } + }) + } + sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(sheet, animated: true) + } +} + +extension OrdersViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + guard currentScenicId != nil else { return 1 } + return viewModel.selectedEntry == .storeOrders ? 3 : 3 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == 0 { return 1 } + if section == 1 { return viewModel.selectedEntry == .storeOrders ? 1 : 1 } + if currentScenicId == nil { return 1 } + if viewModel.selectedEntry == .storeOrders { + return max(viewModel.storeOrders.count, viewModel.loading && viewModel.storeOrders.isEmpty ? 0 : 1) + } + return max(viewModel.writeOffOrders.count, viewModel.loading && viewModel.writeOffOrders.isEmpty ? 0 : 1) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.section == 0 { + let cell = tableView.dequeueReusableCell(withIdentifier: OrdersHeaderCell.reuseID, for: indexPath) as! OrdersHeaderCell + let services = appServices + cell.configure( + selectedEntry: viewModel.selectedEntry, + scenicName: services.accountContext.currentScenic?.name ?? "--", + storeTotal: viewModel.storeTotal, + writeOffTotal: viewModel.writeOffTotal, + onSelectEntry: { [weak self] entry in self?.switchEntry(entry) } + ) + return cell + } + + if currentScenicId == nil { + let cell = UITableViewCell() + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看订单。", systemImage: "mountain.2") + cell.contentView.addSubview(empty) + empty.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalTo(360) + } + return cell + } + + if indexPath.section == 1 { + if viewModel.selectedEntry == .storeOrders { + let cell = tableView.dequeueReusableCell(withIdentifier: OrdersFilterCell.reuseID, for: indexPath) as! OrdersFilterCell + let statusTitle = OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部" + cell.configure( + statusTitle: statusTitle, + phone: viewModel.searchPhone, + onStatus: { [weak self] in self?.presentStatusFilter() }, + onDate: { [weak self] in self?.presentDateFilter() }, + onPhoneChange: { [weak self] text in self?.viewModel.searchPhone = text }, + onSearch: { [weak self] in Task { await self?.reload(showLoading: true) } } + ) + return cell + } + let cell = tableView.dequeueReusableCell(withIdentifier: OrdersWriteOffActionCell.reuseID, for: indexPath) as! OrdersWriteOffActionCell + cell.configure( + manualOrderNumber: manualOrderNumber, + isVerifying: viewModel.isVerifying, + hint: scanHintMessage, + onScan: { [weak self] in self?.presentScanner() }, + onManualChange: { [weak self] text in self?.manualOrderNumber = text }, + onVerify: { [weak self] in + guard let self, !self.manualOrderNumber.isEmpty else { return } + self.confirmVerify(orderNumber: self.manualOrderNumber) + } + ) + return cell + } + + if viewModel.selectedEntry == .storeOrders { + if viewModel.storeOrders.isEmpty { + let cell = UITableViewCell() + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + let empty = makeEmptyStateView(title: "暂无订单", message: "可切换筛选条件或下拉刷新。", systemImage: "tray") + cell.contentView.addSubview(empty) + empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) } + return cell + } + let item = viewModel.storeOrders[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: OrderEntityCell.reuseID, for: indexPath) as! OrderEntityCell + cell.configure(item: item) + return cell + } + + if viewModel.writeOffOrders.isEmpty { + let cell = UITableViewCell() + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + let empty = makeEmptyStateView(title: "暂无核销订单", message: "可下拉刷新或切换景区查看。", systemImage: "tray") + cell.contentView.addSubview(empty) + empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(260) } + return cell + } + let item = viewModel.writeOffOrders[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: WriteOffOrderCell.reuseID, for: indexPath) as! WriteOffOrderCell + cell.configure(item: item, isVerifying: viewModel.currentVerifyingOrderNumber == item.orderNumber) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard indexPath.section == 2 else { return } + if viewModel.selectedEntry == .storeOrders, indexPath.row < viewModel.storeOrders.count { + HomeMenuRouting.pushOrders(.storeDetail(viewModel.storeOrders[indexPath.row]), from: self) + } else if viewModel.selectedEntry == .verificationOrders, indexPath.row < viewModel.writeOffOrders.count { + HomeMenuRouting.pushOrders(.writeOffDetail(viewModel.writeOffOrders[indexPath.row]), from: self) + } + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard indexPath.section == 2 else { return } + let isLast: Bool + if viewModel.selectedEntry == .storeOrders { + isLast = indexPath.row == viewModel.storeOrders.count - 1 + } else { + isLast = indexPath.row == viewModel.writeOffOrders.count - 1 + } + if isLast { Task { await loadMore() } } + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + if indexPath.section == 0 { return 120 } + if indexPath.section == 1 { return viewModel.selectedEntry == .storeOrders ? 130 : 150 } + return UITableView.automaticDimension + } +} + +// MARK: - Cells + +private final class OrdersHeaderCell: UITableViewCell { + static let reuseID = "OrdersHeaderCell" + private var onSelectEntry: ((OrdersEntry) -> Void)? + + private let storeButton = UIButton(type: .system) + private let verifyButton = UIButton(type: .system) + private let leftPill = UILabel() + private let rightPill = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 8 + contentView.addSubview(card) + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) + } + + let segmentBackground = UIView() + segmentBackground.backgroundColor = UIColor(hex: 0xF3F4F6) + segmentBackground.layer.cornerRadius = 8 + card.addSubview(segmentBackground) + storeButton.addTarget(self, action: #selector(storeTapped), for: .touchUpInside) + verifyButton.addTarget(self, action: #selector(verifyTapped), for: .touchUpInside) + segmentBackground.addSubview(storeButton) + segmentBackground.addSubview(verifyButton) + + [leftPill, rightPill].forEach { + $0.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + $0.numberOfLines = 2 + card.addSubview($0) + } + + segmentBackground.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview().inset(12) + make.height.equalTo(40) + } + storeButton.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(2) + make.width.equalToSuperview().multipliedBy(0.5) + } + verifyButton.snp.makeConstraints { make in + make.trailing.top.bottom.equalToSuperview().inset(2) + make.width.equalToSuperview().multipliedBy(0.5) + } + leftPill.snp.makeConstraints { make in + make.leading.bottom.equalToSuperview().inset(12) + make.top.equalTo(segmentBackground.snp.bottom).offset(10) + make.width.equalToSuperview().multipliedBy(0.45) + } + rightPill.snp.makeConstraints { make in + make.trailing.bottom.equalToSuperview().inset(12) + make.top.equalTo(leftPill) + make.width.equalToSuperview().multipliedBy(0.45) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(selectedEntry: OrdersEntry, scenicName: String, storeTotal: Int, writeOffTotal: Int, onSelectEntry: @escaping (OrdersEntry) -> Void) { + self.onSelectEntry = onSelectEntry + updateSegment(storeButton, title: "订单管理", selected: selectedEntry == .storeOrders) + updateSegment(verifyButton, title: "核销订单", selected: selectedEntry == .verificationOrders) + if selectedEntry == .storeOrders { + leftPill.text = "当前景区\n\(scenicName)" + rightPill.text = "订单总数\n\(storeTotal)" + } else { + leftPill.text = "核销订单\n\(writeOffTotal)" + rightPill.text = "当前景区\n\(scenicName)" + } + } + + private func updateSegment(_ button: UIButton, title: String, selected: Bool) { + button.setTitle(title, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium) + button.setTitleColor(selected ? AppDesignUIKit.primary : AppDesignUIKit.textSecondary, for: .normal) + button.backgroundColor = selected ? .white : .clear + button.layer.cornerRadius = 6 + } + + @objc private func storeTapped() { onSelectEntry?(.storeOrders) } + @objc private func verifyTapped() { onSelectEntry?(.verificationOrders) } +} + +private final class OrdersFilterCell: UITableViewCell, UITextFieldDelegate { + static let reuseID = "OrdersFilterCell" + private let phoneField = UITextField() + private var onPhoneChange: ((String) -> Void)? + private var onSearch: (() -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 8 + contentView.addSubview(card) + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) + } + phoneField.delegate = self + phoneField.keyboardType = .phonePad + phoneField.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + phoneField.backgroundColor = UIColor(hex: 0xF4F4F4) + phoneField.layer.cornerRadius = 6 + phoneField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + phoneField.leftViewMode = .always + card.addSubview(phoneField) + phoneField.snp.makeConstraints { make in + make.leading.bottom.equalToSuperview().inset(12) + make.height.equalTo(40) + make.trailing.equalToSuperview().inset(100) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(statusTitle: String, phone: String, onStatus: @escaping () -> Void, onDate: @escaping () -> Void, onPhoneChange: @escaping (String) -> Void, onSearch: @escaping () -> Void) { + phoneField.text = phone + self.onPhoneChange = onPhoneChange + self.onSearch = onSearch + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + onPhoneChange?(textField.text ?? "") + } +} + +private final class OrdersWriteOffActionCell: UITableViewCell, UITextFieldDelegate { + static let reuseID = "OrdersWriteOffActionCell" + private let manualField = UITextField() + private var onManualChange: ((String) -> Void)? + private var onVerify: (() -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 8 + contentView.addSubview(card) + card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } + + manualField.delegate = self + manualField.placeholder = "手动输入订单号" + manualField.autocorrectionType = .no + manualField.autocapitalizationType = .none + manualField.backgroundColor = UIColor(hex: 0xF4F4F4) + manualField.layer.cornerRadius = 8 + manualField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + manualField.leftViewMode = .always + card.addSubview(manualField) + manualField.snp.makeConstraints { make in + make.leading.bottom.equalToSuperview().inset(12) + make.height.equalTo(42) + make.trailing.equalToSuperview().inset(90) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(manualOrderNumber: String, isVerifying: Bool, hint: String?, onScan: @escaping () -> Void, onManualChange: @escaping (String) -> Void, onVerify: @escaping () -> Void) { + manualField.text = manualOrderNumber + self.onManualChange = onManualChange + self.onVerify = onVerify + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + onManualChange?(textField.text ?? "") + } +} + +private final class OrderEntityCell: UITableViewCell { + static let reuseID = "OrderEntityCell" + private let titleLabel = UILabel() + private let statusLabel = UILabel() + private let amountLabel = UILabel() + private let phoneLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .default + backgroundColor = .clear + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 8 + contentView.addSubview(card) + card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } + + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) + statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) + amountLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .semibold) + amountLabel.textColor = AppDesignUIKit.primary + phoneLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + phoneLabel.textColor = AppDesignUIKit.textSecondary + + let stack = UIStackView(arrangedSubviews: [titleLabel, phoneLabel, amountLabel]) + stack.axis = .vertical + stack.spacing = 4 + card.addSubview(stack) + card.addSubview(statusLabel) + stack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(12) } + statusLabel.snp.makeConstraints { make in make.trailing.top.equalToSuperview().inset(12) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(item: OrderEntity) { + titleLabel.text = item.orderNumber + statusLabel.text = item.orderStatusName + amountLabel.text = "¥\(item.actualPayAmount.isEmpty ? item.orderAmount : item.actualPayAmount)" + phoneLabel.text = item.phone + } +} + +private final class WriteOffOrderCell: UITableViewCell { + static let reuseID = "WriteOffOrderCell" + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 8 + contentView.addSubview(card) + card.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 16)) } + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) + subtitleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + subtitleLabel.textColor = AppDesignUIKit.textSecondary + let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + stack.axis = .vertical + stack.spacing = 4 + card.addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(item: WriteOffOrderItem, isVerifying: Bool) { + titleLabel.text = item.orderNumber + subtitleLabel.text = isVerifying ? "核销中..." : (item.projectName.isEmpty ? item.userPhone : item.projectName) + } +} diff --git a/suixinkan_ios/Features/Orders/ViewModels/OrderDetailViewModel.swift b/suixinkan_ios/Features/Orders/ViewModels/OrderDetailViewModel.swift new file mode 100644 index 0000000..e855ce2 --- /dev/null +++ b/suixinkan_ios/Features/Orders/ViewModels/OrderDetailViewModel.swift @@ -0,0 +1,75 @@ +// +// OrderDetailViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 订单详情 ViewModel,负责加载门店订单详情并保留列表摘要兜底展示。 +final class OrderDetailViewModel { + var onChange: (() -> Void)? + private(set) var detail: StoreOrderDetailResponse? { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var errorMessage: String? { didSet { onChange?() } } + private(set) var contextMessage: String? { didSet { onChange?() } } + + /// 当前页面用于展示的订单信息,详情接口成功时优先使用详情数据。 + var display: StoreOrderDetailDisplay { + if let detail { + return StoreOrderDetailDisplay(detail: detail) + } + return StoreOrderDetailDisplay(item: item) + } + + /// 详情中的项目配置。 + var projectInfo: StoreOrderProjectInfo? { + detail?.multiTravel?.projectInfo + } + + /// 详情中的拍摄点列表。 + var shootingList: [StoreOrderShootingListItem] { + detail?.multiTravel?.shootingList ?? [] + } + + private let item: OrderEntity + + /// 初始化详情 ViewModel,并注入列表订单作为兜底数据。 + init(item: OrderEntity) { + self.item = item + } + + /// 加载门店订单详情,缺少门店 ID 时只保留列表摘要展示。 + func load(api: OrderServing, fallbackStoreId: Int?, forceReload: Bool = false) async { + guard !loading else { return } + if forceReload { + detail = nil + } else { + guard detail == nil else { return } + } + + guard let storeId = item.storeId ?? fallbackStoreId, storeId > 0 else { + contextMessage = "缺少门店上下文,当前展示订单摘要。" + return + } + + loading = true + errorMessage = nil + contextMessage = nil + defer { loading = false } + + do { + detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: item.orderNumber) + } catch { + errorMessage = error.localizedDescription + contextMessage = "详情加载失败,当前展示订单摘要。" + } + } + + /// 清理当前错误提示。 + func clearError() { + errorMessage = nil + } +} diff --git a/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift b/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift new file mode 100644 index 0000000..47f337e --- /dev/null +++ b/suixinkan_ios/Features/Orders/ViewModels/OrderLongTailViewModels.swift @@ -0,0 +1,608 @@ +// +// OrderLongTailViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +@MainActor +/// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。 +final class DepositOrderListViewModel { + var onChange: (() -> Void)? + private(set) var orders: [DepositOrderListItem] = [] { didSet { onChange?() } } + private(set) var total = 0 { didSet { onChange?() } } + private(set) var page = 1 { didSet { onChange?() } } + private(set) var hasMore = false { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var loadingMore = false { didSet { onChange?() } } + private(set) var operatingOrderNumber: String? { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 10 + + /// 加载押金订单列表,缺少景区时清空旧数据且不请求接口。 + func reload(api: OrderServing, scenicId: Int?, reset: Bool) async { + guard let scenicId, scenicId > 0 else { + resetState() + return + } + + if reset { + page = 1 + total = 0 + orders = [] + loading = true + } else { + guard !loadingMore, hasMore else { return } + loadingMore = true + } + + defer { + loading = false + loadingMore = false + } + + do { + let result = try await api.depositOrderList(scenicId: scenicId, page: page, pageSize: pageSize) + total = result.total + if page == 1 { + orders = result.list + } else { + orders.append(contentsOf: result.list) + } + page += 1 + hasMore = orders.count < result.total && !result.list.isEmpty + errorMessage = nil + } catch { + if reset { + orders = [] + total = 0 + page = 1 + hasMore = false + } + errorMessage = error.localizedDescription + } + } + + /// 核销押金订单,成功后刷新第一页。 + func writeOff(api: OrderServing, scenicId: Int?, orderNumber: String) async -> Bool { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, operatingOrderNumber == nil else { return false } + guard let scenicId, scenicId > 0 else { + errorMessage = "缺少景区上下文" + return false + } + + operatingOrderNumber = normalized + defer { operatingOrderNumber = nil } + + do { + try await api.depositOrderWriteOff(orderNumber: normalized) + await reload(api: api, scenicId: scenicId, reset: true) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 申请押金订单退款,原因必填,成功后刷新第一页。 + func refund(api: OrderServing, scenicId: Int?, orderNumber: String, reason: String) async -> Bool { + let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedOrderNumber.isEmpty, operatingOrderNumber == nil else { return false } + guard !normalizedReason.isEmpty else { + errorMessage = "请填写退款原因" + return false + } + guard let scenicId, scenicId > 0 else { + errorMessage = "缺少景区上下文" + return false + } + + operatingOrderNumber = normalizedOrderNumber + defer { operatingOrderNumber = nil } + + do { + try await api.depositOrderRefund(orderNumber: normalizedOrderNumber, refundReason: normalizedReason) + await reload(api: api, scenicId: scenicId, reset: true) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func resetState() { + orders = [] + total = 0 + page = 1 + hasMore = false + loading = false + loadingMore = false + operatingOrderNumber = nil + errorMessage = nil + } +} + +@MainActor +/// 押金订单详情 ViewModel,负责按门店和订单号加载详情。 +final class DepositOrderDetailViewModel { + var onChange: (() -> Void)? + private(set) var detail: StoreOrderDetailResponse? { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 加载押金订单详情,缺少门店或订单号时不请求接口。 + func load(api: OrderServing, storeId: Int?, orderNumber: String) async { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard let storeId, storeId > 0 else { + reset(message: "当前账号缺少门店信息") + return + } + guard !normalized.isEmpty else { + reset(message: "订单号不能为空") + return + } + + detail = nil + loading = true + defer { loading = false } + + do { + detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: normalized) + errorMessage = nil + } catch { + detail = nil + errorMessage = error.localizedDescription + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func reset(message: String) { + detail = nil + loading = false + errorMessage = message + } +} + +@MainActor +/// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。 +final class DepositOrderShootingInfoViewModel { + var onChange: (() -> Void)? + private(set) var detail: StoreOrderShootingDetailResponse? { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 加载押金订单拍摄信息,缺少门店或订单号时不请求接口。 + func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard let storeId, storeId > 0 else { + reset(message: "当前账号缺少门店信息") + return + } + guard !normalized.isEmpty else { + reset(message: "订单号不能为空") + return + } + + detail = nil + loading = true + defer { loading = false } + + do { + detail = try await api.storeOrderShootingDetail( + storeId: storeId, + orderNumber: normalized, + scenicSpotId: scenicSpotId, + photogUid: photogUid + ) + errorMessage = nil + } catch { + detail = nil + errorMessage = error.localizedDescription + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func reset(message: String) { + detail = nil + loading = false + errorMessage = message + } +} + +@MainActor +/// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。 +final class OrderRefundViewModel { + var onChange: (() -> Void)? + var mode: OrderRefundMode = .full { didSet { onChange?() } } + var amount = "" { didSet { onChange?() } } + var reason = "" { didSet { onChange?() } } + private(set) var submitting = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 判断订单是否允许展示普通退款入口。 + func canRefund(_ item: OrderEntity) -> Bool { + (item.orderStatus == 18 || item.orderStatus == 30) && + item.orderType != 19 && + availableAmount(for: item) > 0 + } + + /// 打开退款表单时初始化默认值。 + func begin(item: OrderEntity) { + mode = .full + amount = availableAmountText(for: item) + reason = "" + errorMessage = nil + } + + /// 提交普通订单退款。 + func submit(api: OrderServing, item: OrderEntity) async -> Bool { + guard !submitting else { return false } + guard canRefund(item) else { + errorMessage = "当前订单不可退款" + return false + } + let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedReason.isEmpty else { + errorMessage = "请输入退款原因" + return false + } + + let refundAmount: String + switch mode { + case .full: + refundAmount = availableAmountText(for: item) + case .partial: + guard let normalized = Self.normalizedMoney(amount) else { + errorMessage = "请输入有效的退款金额" + return false + } + guard (Decimal(string: normalized) ?? 0) <= availableAmount(for: item) else { + errorMessage = "退款金额不能大于可退金额" + return false + } + refundAmount = normalized + } + + submitting = true + errorMessage = nil + defer { submitting = false } + + do { + try await api.orderRefund( + orderNumber: item.orderNumber, + refundType: mode, + refundAmount: refundAmount, + refundReason: normalizedReason + ) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 计算普通订单可退金额。 + func availableAmount(for item: OrderEntity) -> Decimal { + let paid = Self.decimalValue(item.actualPayAmount) + if paid > 0 { return paid } + return Self.decimalValue(item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount) + } + + /// 返回可退金额展示和提交文案。 + func availableAmountText(for item: OrderEntity) -> String { + Self.moneyText(availableAmount(for: item)) + } + + /// 将输入金额规范成两位小数,空值、非法值和超过两位小数返回 nil。 + static func normalizedMoney(_ rawValue: String) -> String? { + let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "") + guard !text.isEmpty else { return nil } + let pattern = #"^\d+(\.\d{1,2})?$"# + guard text.range(of: pattern, options: .regularExpression) != nil, + let value = Decimal(string: text), + value > 0 else { + return nil + } + return moneyText(value) + } + + private static func decimalValue(_ text: String) -> Decimal { + Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0 + } + + private static func moneyText(_ value: Decimal) -> String { + let number = NSDecimalNumber(decimal: value) + let handler = NSDecimalNumberHandler( + roundingMode: .plain, + scale: 2, + raiseOnExactness: false, + raiseOnOverflow: false, + raiseOnUnderflow: false, + raiseOnDivideByZero: false + ) + return String(format: "%.2f", number.rounding(accordingToBehavior: handler).doubleValue) + } +} + +@MainActor +/// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。 +final class HistoricalShootingInfoViewModel { + var onChange: (() -> Void)? + private(set) var projectName = "" { didSet { onChange?() } } + private(set) var projectTypeName = "" { didSet { onChange?() } } + private(set) var spots: [PhotogSpotItem] = [] { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 加载历史拍摄信息,订单号为空时清空旧数据且不请求接口。 + func load(api: OrderServing, orderNumber: String) async { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + reset(message: "订单号不能为空") + return + } + + loading = true + defer { loading = false } + + do { + let result = try await api.multiTravelShootHistory(orderNumber: normalized) + projectName = result.projectName + projectTypeName = result.projectTypeName + spots = result.photogSpotList + errorMessage = nil + } catch { + reset(message: error.localizedDescription) + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func reset(message: String) { + projectName = "" + projectTypeName = "" + spots = [] + loading = false + errorMessage = message + } +} + +@MainActor +/// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。 +final class MultiTravelTaskUploadViewModel { + var onChange: (() -> Void)? + var orderNumber = "" { didSet { onChange?() } } + var selectedSpotId: Int? { didSet { onChange?() } } + private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = [] { didSet { onChange?() } } + var selectedCloudFiles: [TaskCloudSelectionItem] = [] { didSet { onChange?() } } + var selectedLocalFiles: [TaskLocalUploadItem] = [] { didSet { onChange?() } } + private(set) var isLoadingSpots = false { didSet { onChange?() } } + private(set) var isSubmitting = false { didSet { onChange?() } } + private(set) var didSubmitSuccessfully = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + private var loadedSpotOrderNumber = "" + + /// 当前选中打卡点展示名。 + var selectedSpotName: String { + spots.first(where: { $0.id == selectedSpotId })?.name ?? "请选择打卡点" + } + + /// 判断提交按钮是否满足基础启用条件。 + var canSubmit: Bool { + !isSubmitting && + selectedSpotId != nil && + (!selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty) && + !selectedLocalFiles.contains(where: \.isUploading) + } + + /// 初始化任务上传表单订单号。 + init(initialOrderNumber: String = "") { + orderNumber = initialOrderNumber + } + + /// 加载当前订单已核销打卡点,订单号为空时不请求接口。 + func loadSpots(api: OrderServing) async { + let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { + resetSpotSelection() + return + } + + isLoadingSpots = true + defer { isLoadingSpots = false } + + do { + let result = try await api.multiTravelVerifiedScenicSpotList(orderNumber: normalized) + spots = result + loadedSpotOrderNumber = normalized + if !spots.contains(where: { $0.id == selectedSpotId }) { + selectedSpotId = spots.first?.id + } + errorMessage = nil + } catch { + resetSpotSelection() + errorMessage = error.localizedDescription + } + } + + /// 切换选中的打卡点。 + func selectSpot(id: Int?) { + selectedSpotId = id + } + + /// 合并云盘附件,按文件 ID 去重。 + func mergeCloudFiles(_ files: [TaskCloudSelectionItem]) { + var next = selectedCloudFiles + for file in files where !next.contains(where: { $0.id == file.id }) { + next.append(file) + } + selectedCloudFiles = next + } + + /// 移除指定云盘附件。 + func removeCloudFile(id: Int) { + selectedCloudFiles.removeAll { $0.id == id } + } + + /// 添加本地图片或视频附件。 + func addLocalFile(data: Data, fileName: String) { + selectedLocalFiles.append( + TaskLocalUploadItem( + id: UUID(), + data: data, + fileName: fileName, + fileType: Self.fileType(for: fileName), + remark: "", + uploadedURL: nil, + progress: 0, + errorMessage: nil + ) + ) + } + + /// 移除指定本地附件。 + func removeLocalFile(id: UUID) { + selectedLocalFiles.removeAll { $0.id == id } + } + + /// 提交多点旅拍任务素材;本地附件先上传 OSS,再提交最终 URL。 + @discardableResult + func submit(api: OrderServing, uploadService: any OSSUploadServing, scenicId: Int?) async -> Bool { + guard !isSubmitting else { return false } + guard let requestContext = validateBeforeSubmit(scenicId: scenicId) else { return false } + + isSubmitting = true + didSubmitSuccessfully = false + errorMessage = nil + defer { isSubmitting = false } + + do { + let uploadFiles = try await uploadLocalFiles(uploadService: uploadService, scenicId: requestContext.scenicId) + try await api.multiTravelUploadMaterial( + MultiTravelUploadMaterialRequest( + orderNumber: requestContext.orderNumber, + scenicSpotId: requestContext.scenicSpotId, + cloudFile: selectedCloudFiles.map { MultiTravelCloudFileItem(fileId: $0.id) }, + uploadFile: uploadFiles + ) + ) + didSubmitSuccessfully = true + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 清理错误文案。 + func clearError() { + errorMessage = nil + } + + private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? { + let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + guard let scenicId, scenicId > 0 else { + errorMessage = "请先选择景区" + return nil + } + guard !normalizedOrderNumber.isEmpty else { + errorMessage = "请先填写订单号" + return nil + } + guard loadedSpotOrderNumber == normalizedOrderNumber else { + errorMessage = "请先刷新打卡点" + return nil + } + guard let selectedSpotId else { + errorMessage = "请选择打卡点" + return nil + } + guard !selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty else { + errorMessage = "请至少选择一个素材文件" + return nil + } + guard !selectedLocalFiles.contains(where: \.isUploading) else { + errorMessage = "文件上传中,请稍后提交" + return nil + } + return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId) + } + + private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] { + var uploadFiles: [MultiTravelUploadFileItem] = [] + for index in selectedLocalFiles.indices { + if let uploadedURL = selectedLocalFiles[index].uploadedURL { + uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: uploadedURL)) + continue + } + + selectedLocalFiles[index].errorMessage = nil + let fileID = selectedLocalFiles[index].id + do { + let url = try await uploadService.uploadTaskFile( + data: selectedLocalFiles[index].data, + fileName: selectedLocalFiles[index].fileName, + fileType: selectedLocalFiles[index].fileType, + scenicId: scenicId, + onProgress: { [weak self] progress in + Task { @MainActor in + self?.updateLocalFileProgress(id: fileID, progress: progress) + } + } + ) + selectedLocalFiles[index].uploadedURL = url + selectedLocalFiles[index].progress = 100 + uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: url)) + } catch { + selectedLocalFiles[index].progress = 0 + selectedLocalFiles[index].errorMessage = error.localizedDescription + throw error + } + } + return uploadFiles + } + + private func updateLocalFileProgress(id: UUID, progress: Int) { + guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return } + selectedLocalFiles[index].progress = progress + } + + private func resetSpotSelection() { + spots = [] + selectedSpotId = nil + loadedSpotOrderNumber = "" + isLoadingSpots = false + } + + private static func fileType(for fileName: String) -> Int { + let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() + return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2 + } + + private struct SubmitContext { + let scenicId: Int + let orderNumber: String + let scenicSpotId: Int + } +} diff --git a/suixinkan_ios/Features/Orders/ViewModels/OrdersViewModel.swift b/suixinkan_ios/Features/Orders/ViewModels/OrdersViewModel.swift new file mode 100644 index 0000000..5307918 --- /dev/null +++ b/suixinkan_ios/Features/Orders/ViewModels/OrdersViewModel.swift @@ -0,0 +1,213 @@ +// +// OrdersViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 订单页面 ViewModel,管理订单列表、核销列表、筛选条件和手动核销流程。 +final class OrdersViewModel { + var onChange: (() -> Void)? + var selectedEntry: OrdersEntry = .storeOrders { didSet { onChange?() } } + var selectedStatus = 0 { didSet { onChange?() } } + var searchPhone = "" { didSet { onChange?() } } + var filterStartDate: Date? { didSet { onChange?() } } + var filterEndDate: Date? { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var loadingMore = false { didSet { onChange?() } } + private(set) var isVerifying = false { didSet { onChange?() } } + private(set) var currentVerifyingOrderNumber: String? { didSet { onChange?() } } + + private(set) var storeOrders: [OrderEntity] = [] { didSet { onChange?() } } + private(set) var storeTotal = 0 { didSet { onChange?() } } + private(set) var storePage = 1 { didSet { onChange?() } } + private(set) var storeHasMore = false { didSet { onChange?() } } + + private(set) var writeOffOrders: [WriteOffOrderItem] = [] { didSet { onChange?() } } + private(set) var writeOffTotal = 0 { didSet { onChange?() } } + private(set) var writeOffPage = 1 { didSet { onChange?() } } + private(set) var writeOffHasMore = false { didSet { onChange?() } } + + /// 返回去除首尾空白后的手机号搜索值。 + var normalizedPhone: String? { + let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + + /// 根据当前子入口刷新对应列表。 + func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, roleId: Int?, showLoading: Bool = true) async throws { + switch selectedEntry { + case .storeOrders: + try await reloadStoreOrders(api: api, scenicId: scenicId, roleId: roleId, showLoading: showLoading) + case .verificationOrders: + try await reloadWriteOffOrders(api: api, scenicId: scenicId, storeId: storeId, showLoading: showLoading) + } + } + + /// 刷新订单管理列表,缺少景区时清空旧数据。 + func reloadStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws { + guard let scenicId else { + storeOrders = [] + storeTotal = 0 + storePage = 1 + storeHasMore = false + return + } + + if showLoading { loading = true } + defer { if showLoading { loading = false } } + + let result = try await api.orderList( + scenicId: scenicId, + page: 1, + pageSize: pageSize, + orderStatus: orderStatusQuery, + userPhone: normalizedPhone, + startTime: startTimeQuery, + endTime: endTimeQuery, + isRefined: refinedQuery, + isScenicAdmin: roleId == 53 + ) + storeOrders = result.list + storeTotal = result.total + storePage = 1 + storeHasMore = storeOrders.count < result.total && !result.list.isEmpty + } + + /// 加载订单管理下一页。 + func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?) async throws { + guard !loadingMore, storeHasMore, let scenicId else { return } + loadingMore = true + defer { loadingMore = false } + + let nextPage = storePage + 1 + let result = try await api.orderList( + scenicId: scenicId, + page: nextPage, + pageSize: pageSize, + orderStatus: orderStatusQuery, + userPhone: normalizedPhone, + startTime: startTimeQuery, + endTime: endTimeQuery, + isRefined: refinedQuery, + isScenicAdmin: roleId == 53 + ) + storeOrders.append(contentsOf: result.list) + storeTotal = result.total + storePage = nextPage + storeHasMore = storeOrders.count < result.total && !result.list.isEmpty + } + + /// 刷新核销订单列表,缺少景区时清空旧数据。 + func reloadWriteOffOrders(api: OrderServing, scenicId: Int?, storeId: Int? = nil, showLoading: Bool = true) async throws { + guard let scenicId else { + writeOffOrders = [] + writeOffTotal = 0 + writeOffPage = 1 + writeOffHasMore = false + return + } + + if showLoading { loading = true } + defer { if showLoading { loading = false } } + + let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize) + writeOffOrders = result.list + writeOffTotal = result.total + writeOffPage = 1 + writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty + } + + /// 加载核销订单下一页。 + func loadMoreWriteOffOrders(api: OrderServing, scenicId: Int?, storeId: Int? = nil) async throws { + guard !loadingMore, writeOffHasMore, let scenicId else { return } + loadingMore = true + defer { loadingMore = false } + + let nextPage = writeOffPage + 1 + let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: nextPage, pageSize: pageSize) + writeOffOrders.append(contentsOf: result.list) + writeOffTotal = result.total + writeOffPage = nextPage + writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty + } + + /// 手动核销订单,并在成功后刷新核销列表。 + func verify(api: OrderServing, scenicId: Int, storeId: Int? = nil, orderNumber: String) async throws { + guard !isVerifying else { return } + + isVerifying = true + currentVerifyingOrderNumber = orderNumber + defer { + isVerifying = false + currentVerifyingOrderNumber = nil + } + + try await api.writeOff(orderNumber: orderNumber) + let refreshed = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize) + writeOffOrders = refreshed.list + writeOffTotal = refreshed.total + writeOffPage = 1 + writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty + } + + /// 解析扫码结果并返回当前核销列表中的匹配订单。 + func matchedWriteOffOrder(for rawCode: String) -> (orderNumber: String, matched: WriteOffOrderItem?)? { + guard let orderNumber = OrderNumberParser.parse(rawCode) else { + return nil + } + let matched = writeOffOrders.first { + $0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame + } + return (matched?.orderNumber ?? orderNumber, matched) + } + + /// 根据订单号查找当前核销列表中的订单。 + func writeOffOrder(matching orderNumber: String) -> WriteOffOrderItem? { + writeOffOrders.first { + $0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame + } + } + + /// 用于测试重复提交场景的内部状态设置。 + func markVerifyingForTests(orderNumber: String) { + isVerifying = true + currentVerifyingOrderNumber = orderNumber + } + + private let pageSize = 20 + + private var orderStatusQuery: Int? { + selectedStatus > 0 ? selectedStatus : nil + } + + private var startTimeQuery: String? { + filterStartDate.map { Self.dayFormatter.string(from: $0) } + } + + private var endTimeQuery: String? { + filterEndDate.map { Self.dayFormatter.string(from: $0) } + } + + private var refinedQuery: Int? { + switch selectedStatus { + case -1: + return 1 + case -2: + return 2 + default: + return nil + } + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} diff --git a/suixinkan_ios/Features/Payment/API/PaymentAPI.swift b/suixinkan_ios/Features/Payment/API/PaymentAPI.swift new file mode 100644 index 0000000..f9eef19 --- /dev/null +++ b/suixinkan_ios/Features/Payment/API/PaymentAPI.swift @@ -0,0 +1,53 @@ +// +// PaymentAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 收款模块服务协议,定义收款码和收款记录接口能力。 +@MainActor +protocol PaymentServing { + /// 获取当前景区的静态收款码和动态收款码基础 URL。 + func payCode(scenicId: Int) async throws -> PayCodeResponse + + /// 获取当前景区的扫码收款记录。 + func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse +} + +@MainActor +/// 收款 API,封装首页“收款”模块需要的网络请求。 +final class PaymentAPI { + private let client: APIClient + + /// 初始化收款 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取当前景区的静态收款码和动态收款码基础 URL。 + func payCode(scenicId: Int) async throws -> PayCodeResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/pay-code", + queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))] + ) + ) + } + + /// 获取当前景区的扫码收款记录。 + func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/order/photo-scan-order-list", + queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))] + ) + ) + } +} + +extension PaymentAPI: PaymentServing {} diff --git a/suixinkan_ios/Features/Payment/Models/PaymentModels.swift b/suixinkan_ios/Features/Payment/Models/PaymentModels.swift new file mode 100644 index 0000000..3c7f8ee --- /dev/null +++ b/suixinkan_ios/Features/Payment/Models/PaymentModels.swift @@ -0,0 +1,182 @@ +// +// PaymentModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 收款码响应实体,表示静态收款码 URL 和动态收款码基础 URL。 +struct PayCodeResponse: Decodable, Equatable { + let staticPayUrl: String + let dynamicPayUrl: String + + /// 收款码 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case staticPayUrl = "static_pay_url" + case dynamicPayUrl = "dynamic_pay_url" + } + + /// 创建收款码响应实体,主要用于测试替身。 + init(staticPayUrl: String = "", dynamicPayUrl: String = "") { + self.staticPayUrl = staticPayUrl + self.dynamicPayUrl = dynamicPayUrl + } + + /// 自定义解码,兼容后端把 URL 字段返回为数字或空值。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + staticPayUrl = try container.decodeLossyString(forKey: .staticPayUrl) + dynamicPayUrl = try container.decodeLossyString(forKey: .dynamicPayUrl) + } +} + +/// 收款记录响应实体,包含后端日汇总和原始记录列表。 +struct PaymentCollectionRecordResponse: Decodable, Equatable { + let analyse: [PaymentCollectionRecordAnalyseItem] + let list: [PaymentCollectionRecordItem] + + /// 收款记录响应 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case analyse + case list + } + + /// 创建收款记录响应实体,主要用于测试替身。 + init(analyse: [PaymentCollectionRecordAnalyseItem] = [], list: [PaymentCollectionRecordItem] = []) { + self.analyse = analyse + self.list = list + } + + /// 自定义解码,兼容后端字段缺失。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + analyse = try container.decodeIfPresent([PaymentCollectionRecordAnalyseItem].self, forKey: .analyse) ?? [] + list = try container.decodeIfPresent([PaymentCollectionRecordItem].self, forKey: .list) ?? [] + } +} + +/// 收款日汇总实体,表示某一天的收款笔数和金额。 +struct PaymentCollectionRecordAnalyseItem: Decodable, Equatable, Identifiable { + let date: String + let orderCount: Int + let orderAmountSum: String + + var id: String { date } + + /// 收款日汇总 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case date + case orderCount = "order_count" + case orderAmountSum = "order_amount_sum" + } + + /// 创建收款日汇总实体,主要用于列表兜底和测试。 + init(date: String, orderCount: Int, orderAmountSum: String) { + self.date = date + self.orderCount = orderCount + self.orderAmountSum = orderAmountSum + } + + /// 自定义解码,兼容金额和数量字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decodeLossyString(forKey: .date) + orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0 + orderAmountSum = try container.decodeLossyString(forKey: .orderAmountSum) + } +} + +/// 收款记录实体,表示一次扫码收款订单。 +struct PaymentCollectionRecordItem: Decodable, Equatable, Identifiable { + let orderNumber: String + let userPhone: String + let orderAmount: String + let createDate: String + let createTime: String + + var id: String { + orderNumber.isEmpty ? "\(createDate)-\(createTime)-\(orderAmount)" : orderNumber + } + + /// 收款记录 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case orderNumber = "order_number" + case userPhone = "user_phone" + case orderAmount = "order_amount" + case createDate = "create_date" + case createTime = "create_time" + } + + /// 创建收款记录实体,主要用于测试替身。 + init(orderNumber: String, userPhone: String, orderAmount: String, createDate: String, createTime: String) { + self.orderNumber = orderNumber + self.userPhone = userPhone + self.orderAmount = orderAmount + self.createDate = createDate + self.createTime = createTime + } + + /// 自定义解码,兼容订单号、手机号和金额字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + userPhone = try container.decodeLossyString(forKey: .userPhone) + orderAmount = try container.decodeLossyString(forKey: .orderAmount) + createDate = try container.decodeLossyString(forKey: .createDate) + createTime = try container.decodeLossyString(forKey: .createTime) + } +} + +/// 收款记录分组实体,表示一个日期下的汇总和明细。 +struct PaymentCollectionRecordGroup: Equatable, Identifiable { + let analyse: PaymentCollectionRecordAnalyseItem + let items: [PaymentCollectionRecordItem] + + var id: String { analyse.id } +} + +/// 收款状态实体,表示当前动态收款轮询结果。 +enum PaymentCollectionStatus: Equatable { + case idle + case waiting + case success(PaymentCollectionRecordItem) + case failed(String) +} + +private extension KeyedDecodingContainer { + /// 将字符串、数字或空值宽松解码为字符串。 + 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 value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value) + } + return "" + } + + /// 将字符串或数字宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan_ios/Features/Payment/Payment.md b/suixinkan_ios/Features/Payment/Payment.md new file mode 100644 index 0000000..36549be --- /dev/null +++ b/suixinkan_ios/Features/Payment/Payment.md @@ -0,0 +1,25 @@ +# 收款模块 + +## 模块职责 + +`Features/Payment` 承接首页 `payment_collection`、`payment_qr`、`payment_code` 权限入口,负责当前景区下的收款码、动态金额二维码、收款记录和到账轮询。 + +## 代码结构 + +- `PaymentAPI`:封装收款码和扫码收款记录接口。 +- `PaymentCollectionViewModel`:管理收款码加载、金额校验、动态二维码生成和到账轮询。 +- `PaymentCollectionRecordViewModel`:管理收款记录加载和按日期分组。 +- `PaymentCollectionView`:展示收款码、金额弹窗、保存二维码和收款状态。 +- `PaymentCollectionRecordView`:展示收款记录明细。 + +## 数据流 + +1. 页面读取 `AccountContext.currentScenic`。 +2. 有景区时调用 `/api/yf-handset-app/photog/pay-code?scenic_id=...` 获取收款码。 +3. 设置金额后基于动态收款码 URL 追加 `amount` 和可选 `remark`,生成二维码。 +4. 开始轮询 `/api/yf-handset-app/photog/order/photo-scan-order-list?scenic_id=...`,最多 60 次,每 2 秒一次。 +5. 命中新收款记录后进入成功态,并使用系统语音播报到账金额。 + +## 缓存边界 + +收款码 URL、付款状态、轮询结果不落盘。保存二维码只写入系统相册,App 不额外缓存图片文件或 Data。 diff --git a/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift b/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift new file mode 100644 index 0000000..ca4e9a2 --- /dev/null +++ b/suixinkan_ios/Features/Payment/ViewControllers/PaymentCollectionViewController.swift @@ -0,0 +1,81 @@ +// +// PaymentCollectionViewController.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import SnapKit +import UIKit + +/// 收款页。 +final class PaymentCollectionViewController: UIViewController { + private let services = AppServices.shared + private let viewModel = PaymentCollectionViewModel() + private let qrImageView = UIImageView() + private let statusLabel = UILabel() + private let amountField = UITextField() + private let remarkField = UITextField() + private let activityIndicator = UIActivityIndicatorView(style: .large) + + override func viewDidLoad() { + super.viewDidLoad() + title = "收款" + view.backgroundColor = UIColor(hex: 0xF5F7FA) + setupUI() + viewModel.onChange = { [weak self] in self?.render() } + Task { await viewModel.loadPayCode(api: services.paymentAPI, scenicId: services.currentScenicId) } + } + + private func setupUI() { + qrImageView.contentMode = .scaleAspectFit + statusLabel.numberOfLines = 0 + statusLabel.textAlignment = .center + statusLabel.textColor = AppDesign.textSecondary + amountField.placeholder = "动态金额" + amountField.borderStyle = .roundedRect + amountField.keyboardType = .decimalPad + remarkField.placeholder = "备注" + remarkField.borderStyle = .roundedRect + + let stack = UIStackView(arrangedSubviews: [qrImageView, statusLabel, amountField, remarkField]) + stack.axis = .vertical + stack.spacing = 12 + view.addSubview(stack) + view.addSubview(activityIndicator) + stack.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(24) + make.leading.trailing.equalToSuperview().inset(24) + } + qrImageView.snp.makeConstraints { $0.height.equalTo(240) } + activityIndicator.snp.makeConstraints { $0.center.equalTo(qrImageView) } + + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(title: "生成", style: .plain, target: self, action: #selector(applyAmount)), + UIBarButtonItem(title: "轮询", style: .plain, target: self, action: #selector(togglePolling)) + ] + } + + private func render() { + qrImageView.image = viewModel.qrImage + statusLabel.text = viewModel.errorMessage ?? String(describing: viewModel.status) + if viewModel.isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() } + } + + @objc private func applyAmount() { + viewModel.amountText = amountField.text ?? "" + viewModel.remarkText = remarkField.text ?? "" + _ = viewModel.applyDynamicAmount() + } + + @objc private func togglePolling() { + Task { + await viewModel.pollUntilPaymentDetected( + api: services.paymentAPI, + scenicId: services.currentScenicId + ) + } + } +} + +extension PaymentCollectionViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/Payment/ViewModels/PaymentViewModel.swift b/suixinkan_ios/Features/Payment/ViewModels/PaymentViewModel.swift new file mode 100644 index 0000000..2321800 --- /dev/null +++ b/suixinkan_ios/Features/Payment/ViewModels/PaymentViewModel.swift @@ -0,0 +1,245 @@ +// +// PaymentViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import CoreImage +import CoreImage.CIFilterBuiltins +import Foundation +import UIKit + +@MainActor +/// 收款页 ViewModel,管理收款码加载、动态金额二维码和到账轮询。 +final class PaymentCollectionViewModel { + var onChange: (() -> Void)? + var staticPayUrl = "" { didSet { onChange?() } } + var dynamicPayUrl = "" { didSet { onChange?() } } + var amountText = "" { didSet { onChange?() } } + var remarkText = "" { didSet { onChange?() } } + var currentPayUrl = "" { didSet { onChange?() } } + var qrImage: UIImage? { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isPolling = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + var status: PaymentCollectionStatus = .idle { didSet { onChange?() } } + + private var knownRecordIDs = Set() + private let context = CIContext() + private let qrFilter = CIFilter.qrCodeGenerator() + + /// 当前静态收款码是否可展示。 + var hasStaticPayCode: Bool { + !staticPayUrl.paymentTrimmed.isEmpty + } + + /// 加载当前景区收款码,无景区时清空收款状态。 + func loadPayCode(api: PaymentServing, scenicId: Int?) async { + guard let scenicId else { + resetPayCode() + errorMessage = "请先选择景区" + return + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let response = try await api.payCode(scenicId: scenicId) + staticPayUrl = response.staticPayUrl + dynamicPayUrl = response.dynamicPayUrl + currentPayUrl = response.staticPayUrl + qrImage = makeQRCode(from: currentPayUrl) + status = .idle + } catch { + errorMessage = error.localizedDescription + } + } + + /// 根据输入金额和备注生成动态二维码 URL。 + @discardableResult + func applyDynamicAmount() -> Bool { + guard let amount = Self.normalizedMoney(amountText), Decimal(string: amount) ?? 0 > 0 else { + errorMessage = "请输入有效收款金额" + return false + } + guard !dynamicPayUrl.paymentTrimmed.isEmpty else { + errorMessage = "动态收款码暂不可用" + return false + } + + var components = URLComponents(string: dynamicPayUrl) + var queryItems = components?.queryItems ?? [] + queryItems.removeAll { $0.name == "amount" || $0.name == "remark" } + queryItems.append(URLQueryItem(name: "amount", value: amount)) + if !remarkText.paymentTrimmed.isEmpty { + queryItems.append(URLQueryItem(name: "remark", value: remarkText.paymentTrimmed)) + } + components?.queryItems = queryItems + currentPayUrl = components?.url?.absoluteString ?? "\(dynamicPayUrl)&amount=\(amount)" + qrImage = makeQRCode(from: currentPayUrl) + amountText = amount + status = .waiting + return true + } + + /// 记录当前已知收款记录,轮询时只把新增记录判定为本次到账。 + func primePaymentRecords(api: PaymentServing, scenicId: Int?) async { + guard let scenicId else { return } + if let response = try? await api.paymentCollectionRecords(scenicId: scenicId) { + knownRecordIDs = Set(response.list.map(\.id)) + } + } + + /// 轮询收款记录,命中新记录后进入成功态。 + func pollUntilPaymentDetected( + api: PaymentServing, + scenicId: Int?, + maxAttempts: Int = 60, + intervalNanoseconds: UInt64 = 2_000_000_000 + ) async { + guard let scenicId else { + status = .failed("请先选择景区") + return + } + guard maxAttempts > 0 else { return } + + isPolling = true + defer { isPolling = false } + + for attempt in 0 ..< maxAttempts { + if Task.isCancelled { return } + do { + let response = try await api.paymentCollectionRecords(scenicId: scenicId) + if let item = matchedNewRecord(in: response.list) { + knownRecordIDs.insert(item.id) + status = .success(item) + return + } + } catch { + status = .failed(error.localizedDescription) + return + } + if attempt < maxAttempts - 1 { + try? await Task.sleep(nanoseconds: intervalNanoseconds) + } + } + status = .failed("暂未查询到到账记录,请稍后刷新收款记录") + } + + /// 清空收款码和轮询状态。 + func resetPayCode() { + staticPayUrl = "" + dynamicPayUrl = "" + currentPayUrl = "" + qrImage = nil + knownRecordIDs = [] + status = .idle + } + + /// 将输入金额规范成最多两位小数。 + static func normalizedMoney(_ rawValue: String) -> String? { + let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + let sanitized = text.replacingOccurrences(of: ",", with: "") + guard let decimal = Decimal(string: sanitized), decimal > 0 else { return nil } + let number = NSDecimalNumber(decimal: decimal) + let handler = NSDecimalNumberHandler( + roundingMode: .plain, + scale: 2, + raiseOnExactness: false, + raiseOnOverflow: false, + raiseOnUnderflow: false, + raiseOnDivideByZero: false + ) + return number.rounding(accordingToBehavior: handler).stringValue + } + + /// 从收款记录中找到本次动态收款对应的新记录。 + private func matchedNewRecord(in records: [PaymentCollectionRecordItem]) -> PaymentCollectionRecordItem? { + let newRecords = records.filter { !knownRecordIDs.contains($0.id) } + guard !newRecords.isEmpty else { return nil } + guard let amount = Self.normalizedMoney(amountText) else { + return newRecords.first + } + return newRecords.first { Self.normalizedMoney($0.orderAmount) == amount } ?? newRecords.first + } + + /// 生成二维码图片,保存相册和页面预览都使用同一张图。 + private func makeQRCode(from text: String) -> UIImage? { + guard !text.paymentTrimmed.isEmpty, + let data = text.data(using: .utf8) else { + return nil + } + qrFilter.setValue(data, forKey: "inputMessage") + qrFilter.setValue("M", forKey: "inputCorrectionLevel") + guard let output = qrFilter.outputImage else { return nil } + let transformed = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)) + guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil } + return UIImage(cgImage: cgImage) + } +} + +private extension String { + /// 收款模块内部使用的去空白文本,避免依赖其他文件的 fileprivate 扩展。 + var paymentTrimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +@MainActor +/// 收款记录 ViewModel,管理收款记录加载、日期分组和汇总兜底。 +final class PaymentCollectionRecordViewModel { + var onChange: (() -> Void)? + var groups: [PaymentCollectionRecordGroup] = [] { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 加载当前景区收款记录,无景区时不请求接口。 + func load(api: PaymentServing, scenicId: Int?) async { + guard let scenicId else { + groups = [] + errorMessage = "请先选择景区" + return + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let response = try await api.paymentCollectionRecords(scenicId: scenicId) + groups = Self.makeGroups(from: response) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 将后端响应转换为可展示日期分组,缺少 analyse 时从 list 兜底生成汇总。 + static func makeGroups(from response: PaymentCollectionRecordResponse) -> [PaymentCollectionRecordGroup] { + if !response.analyse.isEmpty { + return response.analyse.map { analyse in + PaymentCollectionRecordGroup( + analyse: analyse, + items: response.list.filter { $0.createDate == analyse.date } + ) + } + } + + let grouped = Dictionary(grouping: response.list, by: \.createDate) + return grouped.keys.sorted(by: >).map { date in + let items = grouped[date] ?? [] + let total = items.reduce(Decimal(0)) { partial, item in + partial + (Decimal(string: item.orderAmount) ?? 0) + } + let analyse = PaymentCollectionRecordAnalyseItem( + date: date, + orderCount: items.count, + orderAmountSum: NSDecimalNumber(decimal: total).stringValue + ) + return PaymentCollectionRecordGroup(analyse: analyse, items: items) + } + } +} diff --git a/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift b/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift new file mode 100644 index 0000000..e821186 --- /dev/null +++ b/suixinkan_ios/Features/PilotCertification/API/PilotCertificationAPI.swift @@ -0,0 +1,56 @@ +// +// PilotCertificationAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 飞手认证服务协议,定义认证详情、短信、提交和编辑接口。 +@MainActor +protocol PilotCertificationServing { + func flyerDetail() async throws -> FlyerDetailResponse + func flyerSendCode(phone: String) async throws + func flyerApply(_ request: FlyerApplyRequest) async throws + func flyerEdit(_ request: FlyerEditRequest) async throws +} + +@MainActor +/// 飞手认证 API,封装 `/api/app/flyer` 认证相关接口。 +final class PilotCertificationAPI { + private let client: APIClient + + /// 初始化飞手认证 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取飞手认证详情。 + func flyerDetail() async throws -> FlyerDetailResponse { + try await client.send(APIRequest(method: .get, path: "/api/app/flyer/detail")) + } + + /// 发送飞手认证短信验证码。 + func flyerSendCode(phone: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/flyer/send-code", body: FlyerSendCodeRequest(phone: phone)) + ) + } + + /// 首次提交飞手认证申请。 + func flyerApply(_ request: FlyerApplyRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/flyer/apply", body: request) + ) + } + + /// 驳回后编辑并重新提交飞手认证申请。 + func flyerEdit(_ request: FlyerEditRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/flyer/edit", body: request) + ) + } +} + +extension PilotCertificationAPI: PilotCertificationServing {} diff --git a/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift b/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift new file mode 100644 index 0000000..f7ae4ca --- /dev/null +++ b/suixinkan_ios/Features/PilotCertification/Models/PilotCertificationModels.swift @@ -0,0 +1,310 @@ +// +// PilotCertificationModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 飞手证件类型。 +enum PilotCertType: Int, CaseIterable, Identifiable { + case idCard = 1 + case caac = 2 + + var id: Int { rawValue } + + var title: String { + switch self { + case .idCard: + "身份证" + case .caac: + "民用无人机驾驶员执照" + } + } +} + +/// 飞手认证详情响应。 +struct FlyerDetailResponse: Decodable, Equatable { + let id: Int + let name: String + let accountId: String + let realnameStatus: Int + let status: Int + let statusName: String + let submitTime: String + let updatedAt: String + let auditPerson: String + let auditTime: String + let auditNote: String + let certificateType: Int + let certificateNo: String + let certificateStartDate: String + let certificateEndDate: String + let certificateImage: String + let droneModel: String + let droneSn: String + let contactPhone: String + let realnameStatusText: String + let certificationLogs: [FlyerCertificationLogItem] + + enum CodingKeys: String, CodingKey { + case id + case name = "flyer_nickname" + case accountId = "account_id" + case realnameStatus = "realname_status" + case status + case statusName = "status_text" + case submitTime = "created_at" + case updatedAt = "updated_at" + case auditPerson = "reviewer" + case auditTime = "review_time" + case auditNote = "reject_reason" + case certificateType = "certificate_type" + case certificateNo = "certificate_no" + case certificateStartDate = "certificate_start_date" + case certificateEndDate = "certificate_end_date" + case certificateImage = "certificate_image" + case droneModel = "drone_model" + case droneSn = "drone_sn" + case contactPhone = "contact_phone" + case realnameStatusText = "realname_status_text" + case certificationLogs = "flyers_certification_logs" + } + + init( + id: Int = 0, + name: String = "", + accountId: String = "", + realnameStatus: Int = 2, + status: Int = 0, + statusName: String = "", + submitTime: String = "", + updatedAt: String = "", + auditPerson: String = "", + auditTime: String = "", + auditNote: String = "", + certificateType: Int = 2, + certificateNo: String = "", + certificateStartDate: String = "", + certificateEndDate: String = "", + certificateImage: String = "", + droneModel: String = "", + droneSn: String = "", + contactPhone: String = "", + realnameStatusText: String = "", + certificationLogs: [FlyerCertificationLogItem] = [] + ) { + self.id = id + self.name = name + self.accountId = accountId + self.realnameStatus = realnameStatus + self.status = status + self.statusName = statusName + self.submitTime = submitTime + self.updatedAt = updatedAt + self.auditPerson = auditPerson + self.auditTime = auditTime + self.auditNote = auditNote + self.certificateType = certificateType + self.certificateNo = certificateNo + self.certificateStartDate = certificateStartDate + self.certificateEndDate = certificateEndDate + self.certificateImage = certificateImage + self.droneModel = droneModel + self.droneSn = droneSn + self.contactPhone = contactPhone + self.realnameStatusText = realnameStatusText + self.certificationLogs = certificationLogs + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 + name = try container.pilotDecodeLossyString(forKey: .name) + accountId = try container.pilotDecodeLossyString(forKey: .accountId) + realnameStatus = try container.pilotDecodeLossyInt(forKey: .realnameStatus) ?? 2 + status = try container.pilotDecodeLossyInt(forKey: .status) ?? 0 + statusName = try container.pilotDecodeLossyString(forKey: .statusName) + submitTime = try container.pilotDecodeLossyString(forKey: .submitTime) + updatedAt = try container.pilotDecodeLossyString(forKey: .updatedAt) + auditPerson = try container.pilotDecodeLossyString(forKey: .auditPerson) + auditTime = try container.pilotDecodeLossyString(forKey: .auditTime) + auditNote = try container.pilotDecodeLossyString(forKey: .auditNote) + certificateType = try container.pilotDecodeLossyInt(forKey: .certificateType) ?? 2 + certificateNo = try container.pilotDecodeLossyString(forKey: .certificateNo) + certificateStartDate = try container.pilotDecodeLossyString(forKey: .certificateStartDate) + certificateEndDate = try container.pilotDecodeLossyString(forKey: .certificateEndDate) + certificateImage = try container.pilotDecodeLossyString(forKey: .certificateImage) + droneModel = try container.pilotDecodeLossyString(forKey: .droneModel) + droneSn = try container.pilotDecodeLossyString(forKey: .droneSn) + contactPhone = try container.pilotDecodeLossyString(forKey: .contactPhone) + realnameStatusText = try container.pilotDecodeLossyString(forKey: .realnameStatusText) + certificationLogs = try container.decodeIfPresent([FlyerCertificationLogItem].self, forKey: .certificationLogs) ?? [] + } +} + +/// 飞手认证审核日志项。 +struct FlyerCertificationLogItem: Decodable, Identifiable, Equatable { + let id: Int + let flyerId: Int + let operatorName: String + let action: Int + let actionText: String + let rejectReason: String + let remark: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case flyerId = "flyer_id" + case operatorName = "operator" + case action + case actionText = "action_text" + case rejectReason = "reject_reason" + case remark + case createdAt = "created_at" + } + + init( + id: Int = 0, + flyerId: Int = 0, + operatorName: String = "", + action: Int = 0, + actionText: String = "", + rejectReason: String = "", + remark: String = "", + createdAt: String = "" + ) { + self.id = id + self.flyerId = flyerId + self.operatorName = operatorName + self.action = action + self.actionText = actionText + self.rejectReason = rejectReason + self.remark = remark + self.createdAt = createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.pilotDecodeLossyInt(forKey: .id) ?? 0 + flyerId = try container.pilotDecodeLossyInt(forKey: .flyerId) ?? 0 + operatorName = try container.pilotDecodeLossyString(forKey: .operatorName) + action = try container.pilotDecodeLossyInt(forKey: .action) ?? 0 + actionText = try container.pilotDecodeLossyString(forKey: .actionText) + rejectReason = try container.pilotDecodeLossyString(forKey: .rejectReason) + remark = try container.pilotDecodeLossyString(forKey: .remark) + createdAt = try container.pilotDecodeLossyString(forKey: .createdAt) + } +} + +/// 飞手认证短信验证码请求。 +struct FlyerSendCodeRequest: Encodable, Equatable { + let phone: String +} + +/// 飞手首次认证申请请求。 +struct FlyerApplyRequest: Encodable, Equatable { + let name: String + let realnameStatus: Int + let certificateType: Int + let certificateNo: String + let certificateStartDate: String + let certificateEndDate: String + let certificateImage: String + let droneModel: String + let droneSn: String + let contactPhone: String + let code: String + + enum CodingKeys: String, CodingKey { + case name + case realnameStatus = "realname_status" + case certificateType = "certificate_type" + case certificateNo = "certificate_no" + case certificateStartDate = "certificate_start_date" + case certificateEndDate = "certificate_end_date" + case certificateImage = "certificate_image" + case droneModel = "drone_model" + case droneSn = "drone_sn" + case contactPhone = "contact_phone" + case code + } +} + +/// 飞手认证驳回后编辑请求。 +struct FlyerEditRequest: Encodable, Equatable { + let id: Int + let name: String + let realnameStatus: Int + let certificateType: Int + let certificateNo: String + let certificateStartDate: String + let certificateEndDate: String + let certificateImage: String + let droneModel: String + let droneSn: String + let contactPhone: String + let code: String + + enum CodingKeys: String, CodingKey { + case id + case name + case realnameStatus = "realname_status" + case certificateType = "certificate_type" + case certificateNo = "certificate_no" + case certificateStartDate = "certificate_start_date" + case certificateEndDate = "certificate_end_date" + case certificateImage = "certificate_image" + case droneModel = "drone_model" + case droneSn = "drone_sn" + case contactPhone = "contact_phone" + case code + } +} + +/// 飞手认证校验错误。 +enum PilotCertificationValidationError: LocalizedError, Equatable { + case message(String) + + var errorDescription: String? { + switch self { + case .message(let message): + message + } + } +} + +private extension KeyedDecodingContainer { + func pilotDecodeLossyString(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 "" + } + + func pilotDecodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { return intValue } + if let doubleValue = Double(text) { return Int(doubleValue) } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan_ios/Features/PilotCertification/PilotCertification.md b/suixinkan_ios/Features/PilotCertification/PilotCertification.md new file mode 100644 index 0000000..14e764e --- /dev/null +++ b/suixinkan_ios/Features/PilotCertification/PilotCertification.md @@ -0,0 +1,17 @@ +# PilotCertification 模块业务逻辑 + +## 模块职责 + +PilotCertification 负责首页 `pilot_cert` 入口的飞手认证申请。模块展示实名状态、飞手证件、无人机信息、联系方式和审核记录,并支持未提交或驳回状态下重新提交。 + +## 核心流程 + +- `PilotCertificationView` 从环境读取 `ProfileAPI`、`PilotCertificationAPI`、`OSSUploadService` 和当前景区。 +- `PilotCertificationViewModel` 并行加载实名认证状态和飞手认证详情,单通道失败不清空另一通道数据。 +- 用户选择证件图片后先保存在本地状态,提交时通过 OSS 上传并写入 `certificate_image`。 +- 未提交状态调用 `/api/app/flyer/apply`,驳回且有飞手记录 ID 时调用 `/api/app/flyer/edit`。 +- 验证码通过 `/api/app/flyer/send-code` 发送,成功后进入 60 秒本地倒计时。 + +## 边界 + +审核通过后页面只读。本模块不包含 DJI/飞控 SDK、无人机连接、飞行控制或横屏飞控流程。 diff --git a/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift b/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift new file mode 100644 index 0000000..45a9409 --- /dev/null +++ b/suixinkan_ios/Features/PilotCertification/ViewControllers/PilotCertificationViewController.swift @@ -0,0 +1,111 @@ +// +// PilotCertificationViewController.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import SnapKit +import UIKit + +/// 飞手认证页。 +final class PilotCertificationViewController: ModuleTableViewController { + private let viewModel = PilotCertificationViewModel() + private let statusLabel = UILabel() + + override func viewDidLoad() { + title = "飞手认证" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "提交", + style: .done, + target: self, + action: #selector(submit) + ) + super.viewDidLoad() + setupHeader() + wireViewModel(viewModel) { [weak self] in self?.updateHeader() } + } + + private func setupHeader() { + statusLabel.numberOfLines = 0 + statusLabel.font = .systemFont(ofSize: 14) + statusLabel.textColor = AppDesign.textSecondary + statusLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 80) + tableView.tableHeaderView = statusLabel + } + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? formRows.count : 1 + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? "认证信息" : "操作" + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, + for: indexPath + ) as! TitleSubtitleTableViewCell + if indexPath.section == 0 { + let row = formRows[indexPath.row] + cell.configure(title: row.title, subtitle: row.value) + } else { + cell.configure(title: "发送验证码", subtitle: viewModel.phone) + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if indexPath.section == 1 { + Task { + do { + try await viewModel.sendCode(api: services.pilotCertificationAPI) + showToast("验证码已发送") + } catch { + showToast(error.localizedDescription) + } + } + } + } + + override func reloadContent() async { + await viewModel.load(api: services.pilotCertificationAPI, realNameAPI: services.profileAPI) + updateHeader() + } + + @objc private func submit() { + Task { + do { + try await viewModel.submit( + api: services.pilotCertificationAPI, + uploader: services.ossUploadService, + scenicId: services.currentScenicId + ) + services.toastCenter.show("提交成功") + } catch { + services.toastCenter.show(error.localizedDescription) + } + } + } + + private var formRows: [(title: String, value: String)] { + [ + ("姓名", viewModel.name), + ("证件号", viewModel.certNo), + ("手机号", viewModel.phone), + ("无人机型号", viewModel.droneModel), + ("序列号", viewModel.droneSerialNo) + ] + } + + private func updateHeader() { + let status = viewModel.auditStatusText + statusLabel.text = "状态:\(status)\n\(viewModel.statusMessage ?? "")" + } +} + +extension PilotCertificationViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift b/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift new file mode 100644 index 0000000..dcdc2a0 --- /dev/null +++ b/suixinkan_ios/Features/PilotCertification/ViewModels/PilotCertificationViewModel.swift @@ -0,0 +1,281 @@ +// +// PilotCertificationViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +@MainActor +/// 飞手认证实名认证读取协议,便于 ViewModel 单测替换。 +protocol PilotRealNameServing { + func realNameInfo() async throws -> RealNameInfoResponse +} + +extension ProfileAPI: PilotRealNameServing {} + +@MainActor +/// 飞手认证 ViewModel,管理审核状态、表单、验证码、证件图上传和提交。 +final class PilotCertificationViewModel { + var onChange: (() -> Void)? + private(set) var flyer: FlyerDetailResponse? { didSet { onChange?() } } + private(set) var realNameInfo: RealNameInfo? { didSet { onChange?() } } + var name = "" { didSet { onChange?() } } + var certType: PilotCertType = .caac { didSet { onChange?() } } + var certNo = "" { didSet { onChange?() } } + var certImageUrl = "" { didSet { onChange?() } } + var startDate = Date() { didSet { onChange?() } } + var endDate = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date() { didSet { onChange?() } } + var droneModel = "" { didSet { onChange?() } } + var droneSerialNo = "" { didSet { onChange?() } } + var phone = "" { didSet { onChange?() } } + var verifyCode = "" { didSet { onChange?() } } + private(set) var pendingCertificateImageData: Data? { didSet { onChange?() } } + private(set) var pendingCertificateFileName: String? { didSet { onChange?() } } + private(set) var uploadProgress: Int? { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var sendingCode = false { didSet { onChange?() } } + private(set) var submitting = false { didSet { onChange?() } } + private(set) var countdown = 0 { didSet { onChange?() } } + var statusMessage: String? { didSet { onChange?() } } + + /// 加载实名状态和飞手认证详情,单通道失败不清空另一通道数据。 + func load(api: any PilotCertificationServing, realNameAPI: any PilotRealNameServing) async { + guard !loading else { return } + loading = true + defer { loading = false } + + async let realNameTask = realNameAPI.realNameInfo() + async let flyerTask = api.flyerDetail() + var messages: [String] = [] + + do { + let response = try await realNameTask + realNameInfo = response.realNameInfo + } catch { + messages.append(error.localizedDescription) + } + + do { + apply(try await flyerTask) + } catch { + messages.append(error.localizedDescription) + } + + statusMessage = messages.isEmpty ? nil : messages.joined(separator: "\n") + } + + /// 选择待上传证件图。 + func prepareCertificateImage(data: Data, fileName: String) { + pendingCertificateImageData = data + pendingCertificateFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg" + : fileName + certImageUrl = "" + uploadProgress = nil + } + + /// 发送验证码,成功后进入 60 秒倒计时。 + func sendCode(api: any PilotCertificationServing) async throws { + guard canSendCode else { + throw PilotCertificationValidationError.message("请输入有效的手机号码") + } + sendingCode = true + defer { sendingCode = false } + + try await api.flyerSendCode(phone: AppFormValidator.normalizedPhoneNumber(phone)) + countdown = 60 + statusMessage = "验证码已发送" + } + + /// 倒计时递减,由页面定时器驱动。 + func tickCountdown() { + if countdown > 0 { + countdown -= 1 + } + } + + /// 提交飞手认证,必要时先上传证件图。 + func submit(api: any PilotCertificationServing, uploader: any OSSUploadServing, scenicId: Int?) async throws { + if let validationMessage { + throw PilotCertificationValidationError.message(validationMessage) + } + + submitting = true + defer { submitting = false } + + try await uploadPendingCertificateImage(uploader: uploader, scenicId: scenicId) + if auditStatus == 3, flyerId > 0 { + try await api.flyerEdit(makeEditRequest()) + statusMessage = "修改已提交" + } else { + try await api.flyerApply(makeApplyRequest()) + statusMessage = "认证申请已提交" + } + await load(api: api, realNameAPI: PilotEmptyRealNameServing(info: realNameInfo)) + } + + /// 审核状态编码。 + var auditStatus: Int { + flyer?.status ?? 0 + } + + /// 飞手认证记录 ID。 + var flyerId: Int { + flyer?.id ?? 0 + } + + /// 审核通过后表单只读。 + var isReadOnly: Bool { + auditStatus == 2 + } + + /// 实名是否已通过。 + var isRealNameVerified: Bool { + realNameInfo?.verified == true || flyer?.realnameStatus == 2 + } + + /// 是否允许发送验证码。 + var canSendCode: Bool { + !sendingCode && countdown == 0 && (auditStatus == 0 || auditStatus == 3) && AppFormValidator.isValidMainlandPhoneNumber(phone) + } + + /// 当前表单校验错误。 + var validationMessage: String? { + if isReadOnly { return "认证已通过,无需重复提交" } + if auditStatus != 0 && auditStatus != 3 { return "认证审核中,请等待审核结果" } + if name.trimmedForPilot.isEmpty { return "请输入飞手昵称" } + if certNo.trimmedForPilot.isEmpty { return "请输入证件号码" } + if certImageUrl.trimmedForPilot.isEmpty && pendingCertificateImageData == nil { return "请选择证件图片" } + if endDate < startDate { return "截至日期不能早于起始日期" } + if droneModel.trimmedForPilot.isEmpty { return "请输入无人机型号" } + if droneSerialNo.trimmedForPilot.isEmpty { return "请输入无人机序列号" } + if phone.trimmedForPilot.isEmpty { return "请输入手机号码" } + if !AppFormValidator.isValidMainlandPhoneNumber(phone) { return "请输入有效的手机号码" } + if verifyCode.trimmedForPilot.isEmpty { return "请输入验证码" } + return nil + } + + /// 审核状态展示文本。 + var auditStatusText: String { + flyer?.statusName.trimmedForPilot.nonEmptyForPilot ?? Self.statusName(for: auditStatus) + } + + /// 最近审核日志。 + var latestAuditLog: FlyerCertificationLogItem? { + flyer?.certificationLogs.last { $0.action == 2 || $0.action == 3 } + } + + private func apply(_ flyer: FlyerDetailResponse) { + self.flyer = flyer + name = flyer.name + certType = PilotCertType(rawValue: flyer.certificateType) ?? .caac + certNo = flyer.certificateNo + certImageUrl = flyer.certificateImage + startDate = Self.date(from: flyer.certificateStartDate) ?? startDate + endDate = Self.date(from: flyer.certificateEndDate) ?? endDate + droneModel = flyer.droneModel + droneSerialNo = flyer.droneSn + phone = flyer.contactPhone + pendingCertificateImageData = nil + pendingCertificateFileName = nil + uploadProgress = nil + } + + private func uploadPendingCertificateImage(uploader: any OSSUploadServing, scenicId: Int?) async throws { + guard let data = pendingCertificateImageData else { return } + guard let scenicId, scenicId > 0 else { + throw PilotCertificationValidationError.message("请先选择景区") + } + uploadProgress = 1 + defer { uploadProgress = nil } + certImageUrl = try await uploader.uploadPilotCertificateImage( + data: data, + fileName: pendingCertificateFileName ?? "pilot_cert_\(Int(Date().timeIntervalSince1970)).jpg", + scenicId: scenicId + ) { [weak self] progress in + Task { @MainActor in + self?.uploadProgress = progress + } + } + pendingCertificateImageData = nil + pendingCertificateFileName = nil + } + + private func makeApplyRequest() -> FlyerApplyRequest { + FlyerApplyRequest( + name: name.trimmedForPilot, + realnameStatus: isRealNameVerified ? 1 : 2, + certificateType: certType.rawValue, + certificateNo: certNo.trimmedForPilot, + certificateStartDate: Self.dateFormatter.string(from: startDate), + certificateEndDate: Self.dateFormatter.string(from: endDate), + certificateImage: certImageUrl.trimmedForPilot, + droneModel: droneModel.trimmedForPilot, + droneSn: droneSerialNo.trimmedForPilot, + contactPhone: AppFormValidator.normalizedPhoneNumber(phone), + code: verifyCode.trimmedForPilot + ) + } + + private func makeEditRequest() -> FlyerEditRequest { + let apply = makeApplyRequest() + return FlyerEditRequest( + id: flyerId, + name: apply.name, + realnameStatus: apply.realnameStatus, + certificateType: apply.certificateType, + certificateNo: apply.certificateNo, + certificateStartDate: apply.certificateStartDate, + certificateEndDate: apply.certificateEndDate, + certificateImage: apply.certificateImage, + droneModel: apply.droneModel, + droneSn: apply.droneSn, + contactPhone: apply.contactPhone, + code: apply.code + ) + } + + private static func statusName(for status: Int) -> String { + switch status { + case 1: "审核中" + case 2: "已通过" + case 3: "审核失败" + default: "未提交" + } + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static func date(from value: String) -> Date? { + let text = String(value.prefix(10)) + guard !text.isEmpty else { return nil } + return dateFormatter.date(from: text) + } +} + +private struct PilotEmptyRealNameServing: PilotRealNameServing { + let info: RealNameInfo? + + func realNameInfo() async throws -> RealNameInfoResponse { + RealNameInfoResponse(realNameInfo: info) + } +} + +private extension String { + var trimmedForPilot: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + var nonEmptyForPilot: String? { + let value = trimmedForPilot + return value.isEmpty ? nil : value + } +} diff --git a/suixinkan_ios/Features/Profile/API/ProfileAPI.swift b/suixinkan_ios/Features/Profile/API/ProfileAPI.swift new file mode 100644 index 0000000..e0a4ed8 --- /dev/null +++ b/suixinkan_ios/Features/Profile/API/ProfileAPI.swift @@ -0,0 +1,96 @@ +// +// ProfileAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation + +@MainActor +/// 个人信息 API,封装“我的”页面需要的用户资料和资料更新接口。 +final class ProfileAPI { + 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 switchableAccounts() async throws -> V9AuthResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/v9/accounts" + ) + ) + } + + /// 拉取当前登录用户的实名认证状态。 + 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)] + ) + ) + } + + /// 发送实名认证短信验证码。 + func realNameSmsVerifyCode() async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/real-name/sms-verify-code", + body: EmptyPayload() + ) + ) + } + + /// 提交实名认证资料。 + func realNameSubmit(_ request: RealNameAuthRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/real-name/submit", + body: request + ) + ) + } +} + +extension ProfileAPI: UserProfileServing {} diff --git a/suixinkan_ios/Features/Profile/Models/AgreementPage.swift b/suixinkan_ios/Features/Profile/Models/AgreementPage.swift new file mode 100644 index 0000000..8c9e8de --- /dev/null +++ b/suixinkan_ios/Features/Profile/Models/AgreementPage.swift @@ -0,0 +1,48 @@ +// +// AgreementPage.swift +// suixinkan +// + +import Foundation + +/// 协议和说明页面枚举,描述设置中心可打开的 H5 页面。 +enum AgreementPage: Hashable, Identifiable { + case about + case userAgreement + case privacyPolicy + case walletUserNotice + case walletPrivacy + + var id: String { title } + + /// 页面导航标题。 + var title: String { + switch self { + case .about: "关于我们" + case .userAgreement: "用户协议" + case .privacyPolicy: "隐私政策" + case .walletUserNotice: "钱包用户须知" + case .walletPrivacy: "钱包隐私政策" + } + } + + /// 页面对应的 H5 地址。 + var url: URL { + let path = switch self { + case .about: "/h5/app/about-us" + case .userAgreement: "/h5/app/user-agreement" + case .privacyPolicy: "/h5/app/privacy-policy" + case .walletUserNotice: "/h5/app/wallet-user-notice" + case .walletPrivacy: "/h5/app/wallet-privacy" + } + return APIEnvironment.current.baseURL.appending(path: path) + } +} + +/// 设置展示策略,集中处理版本号等纯展示逻辑。 +enum SettingsDisplayPolicy { + /// 生成页面显示的版本号。 + nonisolated static func versionText(infoDictionary: [String: Any]? = Bundle.main.infoDictionary) -> String { + AppClientInfo.appVersion(infoDictionary: infoDictionary) + } +} diff --git a/suixinkan_ios/Features/Profile/Models/ProfileModels.swift b/suixinkan_ios/Features/Profile/Models/ProfileModels.swift new file mode 100644 index 0000000..fe785fd --- /dev/null +++ b/suixinkan_ios/Features/Profile/Models/ProfileModels.swift @@ -0,0 +1,217 @@ +// +// 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 idCardNo: String + let auditStatus: Int + let auditStatusText: String? + let rejectReason: String? + let startDate: String? + let endDate: String? + let isLongValid: Bool + let frontUrl: String? + let backUrl: String? + let auditorId: Int? + let auditor: RealNameAuditor? + let auditAt: String? + + /// 当前认证是否已审核通过。 + var verified: Bool { + auditStatus == 2 + } + + /// 实名认证信息的 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case realName = "real_name" + case idCardNo = "id_card_no" + case auditStatus = "audit_status" + case auditStatusText = "audit_status_text" + case rejectReason = "reject_reason" + case startDate = "start_date" + case endDate = "end_date" + case isLongValid = "is_long_valid" + case frontUrl = "front_url" + case backUrl = "back_url" + case auditorId = "auditor_id" + case auditor + case auditAt = "audit_at" + } + + /// 自定义解码,兼容审核状态字段类型不稳定的情况。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + realName = try container.decodeLossyString(forKey: .realName) + idCardNo = try container.decodeLossyString(forKey: .idCardNo) + auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0 + auditStatusText = try container.decodeIfPresent(String.self, forKey: .auditStatusText) + rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason) + startDate = try container.decodeIfPresent(String.self, forKey: .startDate) + endDate = try container.decodeIfPresent(String.self, forKey: .endDate) + isLongValid = (try container.decodeLossyInt(forKey: .isLongValid) ?? 0) == 1 + frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl) + backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl) + auditorId = try container.decodeLossyInt(forKey: .auditorId) + auditor = try container.decodeIfPresent(RealNameAuditor.self, forKey: .auditor) + auditAt = try container.decodeIfPresent(String.self, forKey: .auditAt) + } +} + +/// 实名认证审核人实体,表示后台审核人的基础展示信息。 +struct RealNameAuditor: Decodable, Equatable { + let id: Int + let name: String + + /// 审核人 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case 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 RealNameAuthRequest: Encodable, Equatable { + let realName: String + let idCardNo: String + let smsVerifyCode: String + let startDate: String + let endDate: String + let isLongValid: Int + let frontUrl: String + let backUrl: String + + /// 实名认证提交请求 JSON 字段映射。 + enum CodingKeys: String, CodingKey { + case realName = "real_name" + case idCardNo = "id_card_no" + case smsVerifyCode = "sms_verify_code" + case startDate = "start_date" + case endDate = "end_date" + case isLongValid = "is_long_valid" + case frontUrl = "front_url" + case backUrl = "back_url" + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan_ios/Features/Profile/Profile.md b/suixinkan_ios/Features/Profile/Profile.md new file mode 100644 index 0000000..f26b287 --- /dev/null +++ b/suixinkan_ios/Features/Profile/Profile.md @@ -0,0 +1,122 @@ +# Profile 模块业务逻辑 + +## 模块职责 + +Profile 模块负责“我的/个人信息”页面及其二级页面,包括用户资料展示、昵称编辑、账号切换、密码修改、实名认证、系统设置、协议页和退出登录。 + +该模块聚焦个人资料业务: +- 拉取用户基础资料。 +- 拉取实名认证信息。 +- 展示头像、昵称、UID、手机号、账号状态和实名认证状态。 +- 修改昵称和头像。 +- 修改密码。 +- 切换当前景区/门店业务账号。 +- 提交实名认证基础资料。 +- 展示系统设置、版本号、下载链接和协议 H5 页面。 +- 退出登录入口。 + +登录态清理和缓存清理由 App 模块的 `AuthSessionCoordinator` 统一处理。 + +DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,进入 `DebugHomeMenuPreviewView` 后可查看并跳转所有已知首页菜单,不读取角色权限。该入口只用于迁移预览和调试,不进入 Release 包。 + +## 核心对象 + +- `ProfileView`:个人信息页 UI,负责展示资料、编辑入口、密码弹窗和退出确认。 +- `ProfileViewModel`:维护资料加载状态、编辑状态、保存状态和展示文案。 +- `ProfileRoute`:个人中心二级页面路由。 +- `AccountSwitchView` / `AccountSwitchViewModel`:账号切换页面和状态逻辑。 +- `RealNameAuthView` / `RealNameAuthViewModel`:实名认证页面和表单逻辑。 +- `SettingsCenterView` / `AgreementView`:设置中心和协议 H5 页面。 +- `DebugHomeMenuPreviewView`:DEBUG 专用首页菜单预览页,不参与正式业务流程。 +- `ProfileAPI`:封装用户资料、实名认证和资料更新接口。 +- `OSSUploadService`:上传头像和实名认证证件图片,返回可提交给业务接口的 OSS URL。 +- `UserInfoResponse`:用户基础资料。 +- `RealNameInfoResponse` / `RealNameInfo`:实名认证信息。 +- `UpdateInfoRequest`:昵称、密码或头像更新请求体。 +- `RealNameAuthRequest`:实名认证提交请求体。 + +## 加载流程 + +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. 用户点击头像时通过 `PhotosPicker` 选择本地图片。 +4. `AvatarImageProcessor` 将头像压缩为 JPEG,并暂存在内存中。 +5. 用户提交后,`ProfileViewModel.saveProfile` 校验昵称非空。 +6. 头像变化时先调用 `OSSUploadService.uploadUserAvatar` 上传到 OSS。 +7. 头像上传成功后调用 `ProfileAPI.updateUserAvatarURL` 回写头像 URL。 +8. 昵称变化时调用 `ProfileAPI.updateUserInfo(nickname:)`。 +9. 接口成功后本地更新 `userInfo.nickname` 和 `userInfo.avatar`。 +10. `ProfileView` 刷新全局账号资料和账号快照。 + +## 密码修改流程 + +1. 用户打开 `PasswordUpdateSheet`。 +2. 输入新密码并点击完成。 +3. `ProfileViewModel.updatePassword` 校验密码至少 6 位。 +4. 校验通过后调用 `ProfileAPI.updateUserInfo(password:)`。 +5. 成功后关闭弹窗并展示 Toast。 + +密码不会写入本地缓存。 + +## 账号切换流程 + +1. 用户点击“当前账号”进入 `AccountSwitchView`。 +2. `AccountSwitchViewModel.load` 调用 `ProfileAPI.switchableAccounts`,读取 `/api/app/v9/accounts`。 +3. 页面展示景区账号和门店账号,优先选中后端标记的当前账号,否则选中第一项。 +4. 用户确认后调用 `AuthAPI.setUser`,使用 `AccountSwitchAccount.toSetUserRequest()` 生成请求体。 +5. 切换成功后调用 `AuthSessionCoordinator.completeLogin`,重新写入正式 token、账号快照、权限、景区和门店上下文。 +6. `AppRouter.reset()` 清空旧路由栈并回到首页。 + +## 实名认证流程 + +1. 用户点击“认证状态”进入 `RealNameAuthView`。 +2. `RealNameAuthViewModel.load` 调用 `ProfileAPI.realNameInfo` 并回填姓名、身份证号、证件有效期、图片 URL 和审核状态。 +3. 用户可发送短信验证码,接口为 `/api/yf-handset-app/photog/real-name/sms-verify-code`。 +4. 用户通过图片卡片选择身份证人像面和国徽面。 +5. `RealNameImageProcessor` 将证件图片压缩为 JPEG,并暂存在内存中。 +6. 提交前校验姓名、身份证号、短信验证码、证件图片和有效期。 +7. 校验通过后先调用 `OSSUploadService.uploadRealNameImage` 上传证件图片。 +8. 上传成功后把 OSS URL 写入 `RealNameAuthRequest.frontUrl/backUrl`。 +9. 调用 `/api/yf-handset-app/photog/real-name/submit` 提交实名资料。 +10. 提交成功后重新加载实名认证信息。 + +证件图片的原始 Data、压缩 Data 和上传进度只保存在内存中,不写入本地缓存。 + +## 设置和协议流程 + +1. 用户点击“系统设置”进入 `SettingsCenterView`。 +2. 设置中心展示关于我们、系统版本、App 下载链接、用户协议和隐私政策。 +3. App 下载点击后复制 `/h5/app/download` 链接到剪贴板。 +4. 协议页使用 `AgreementView` 加载 `APIEnvironment.current.baseURL` 下的 H5 页面。 + +## 退出登录流程 + +1. 用户点击退出登录。 +2. `ProfileView` 展示确认弹窗。 +3. 用户确认后调用 `AuthSessionCoordinator.logout`。 +4. 协调器清空正式 token、账号快照、账号上下文、路由栈和 Toast。 +5. `AppSession` 切换为 `loggedOut`,根视图回到登录页。 +6. 上次手机号和协议状态等非敏感偏好保留。 + +## 状态展示规则 + +- 昵称为空时展示“未设置昵称”。 +- 真实姓名、手机号和账号状态为空时展示 `--`。 +- 实名认证状态: + - `auditStatus == 2`:已实名认证。 + - `auditStatus == 3`:审核不通过。 + - 其他状态:审核中。 + - 无实名认证信息时:点击去实名认证。 +- 账号切换失败只展示 Toast,不清空登录态。 +- 实名认证失败只影响实名认证页面,不影响登录态和账号上下文。 diff --git a/suixinkan_ios/Features/Profile/Routing/ProfileRoute.swift b/suixinkan_ios/Features/Profile/Routing/ProfileRoute.swift new file mode 100644 index 0000000..d98b3db --- /dev/null +++ b/suixinkan_ios/Features/Profile/Routing/ProfileRoute.swift @@ -0,0 +1,19 @@ +// +// ProfileRoute.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 个人中心二级页面路由,集中声明“我的”Tab 可以 push 的页面。 +enum ProfileRoute: Hashable { + case accountSwitch + case realNameAuth + case settings + case agreement(AgreementPage) + #if DEBUG + case debugHomeMenus + #endif +} diff --git a/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift new file mode 100644 index 0000000..40bd878 --- /dev/null +++ b/suixinkan_ios/Features/Profile/ViewControllers/AccountSwitchViewController.swift @@ -0,0 +1,263 @@ +// +// AccountSwitchViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 账号切换页面,展示当前登录主体下可进入的景区账号和门店账号。 +final class AccountSwitchViewController: UIViewController { + + private let viewModel = AccountSwitchViewModel() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .plain) + table.backgroundColor = UIColor(hex: 0xF5F7FB) + table.separatorStyle = .none + table.dataSource = self + table.delegate = self + table.register(AccountSwitchCell.self, forCellReuseIdentifier: AccountSwitchCell.reuseID) + return table + }() + + private lazy var confirmButton: UIButton = { + let button = makePrimaryButton(title: "确认切换") + button.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside) + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + title = "账号切换" + view.backgroundColor = UIColor(hex: 0xF5F7FB) + + view.addSubview(tableView) + view.addSubview(confirmButton) + tableView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(confirmButton.snp.top).offset(-12) + } + confirmButton.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(14) + make.height.equalTo(50) + } + + viewModel.onChange = { [weak self] in + self?.tableView.reloadData() + self?.updateConfirmButton() + } + Task { await loadAccounts() } + } + + private func updateConfirmButton() { + let enabled = viewModel.selectedAccount != nil && !viewModel.loading && !viewModel.switching + confirmButton.isEnabled = enabled + confirmButton.alpha = enabled ? 1 : 0.5 + } + + private func loadAccounts(force: Bool = false) async { + do { + try await appServices.globalLoading.withOptionalLoading(!force && viewModel.accounts.isEmpty, message: "加载中...") { + try await self.viewModel.load( + api: self.appServices.profileAPI, + force: force, + currentAccountId: self.currentAccountId + ) + } + } catch { + showToast(error.localizedDescription) + } + } + + private var currentAccountId: String? { + guard let current = appServices.accountContext.profile else { return nil } + if let store = appServices.accountContext.currentStore { + return "\(V9StoreUser.accountTypeValue)_\(store.id)" + } + if let scenic = appServices.accountContext.currentScenic { + return "\(V9ScenicUser.accountTypeValue)_\(scenic.id)" + } + return current.userId.isEmpty ? nil : current.userId + } + + private func isCurrentAccount(_ account: AccountSwitchAccount) -> Bool { + viewModel.isCurrent(account, currentAccountId: currentAccountId) + } + + @objc private func confirmTapped() { + guard let account = viewModel.selectedAccount else { return } + if account.isCurrent || isCurrentAccount(account) { + navigationController?.popViewController(animated: true) + return + } + Task { await switchAccount(account) } + } + + private func switchAccount(_ account: AccountSwitchAccount) async { + do { + let response = try await appServices.globalLoading.withLoading(message: "切换中...") { + try await self.viewModel.switchAccount(account, api: self.appServices.authAPI) + } + let username = nonEmpty(appServices.accountContext.profile?.phone) + ?? nonEmpty(account.phone) + ?? "" + try await appServices.authSessionCoordinator.completeLogin( + with: response, + username: username, + privacyAgreementAccepted: appServices.authSessionCoordinator.loginPreferences().privacyAgreementAccepted, + appSession: appServices.appSession, + accountContext: appServices.accountContext, + permissionContext: appServices.permissionContext, + profileAPI: appServices.profileAPI, + accountContextAPI: appServices.accountContextAPI + ) + appServices.appRouter.reset() + showToast("账号已切换") + navigationController?.popToRootViewController(animated: true) + } catch { + showToast(error.localizedDescription) + } + } + + private func nonEmpty(_ value: String?) -> String? { + let text = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? nil : text + } +} + +extension AccountSwitchViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.accounts.isEmpty && !viewModel.loading ? 1 : viewModel.accounts.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard !viewModel.accounts.isEmpty else { + let cell = UITableViewCell() + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + let empty = makeEmptyStateView( + title: "暂无可切换账号", + message: "当前登录账号下没有其他可切换账号。", + systemImage: "person.crop.circle.badge.exclamationmark" + ) + cell.contentView.addSubview(empty) + empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(280) } + return cell + } + let cell = tableView.dequeueReusableCell(withIdentifier: AccountSwitchCell.reuseID, for: indexPath) as! AccountSwitchCell + let account = viewModel.accounts[indexPath.row] + cell.configure( + account: account, + selected: viewModel.selectedAccountId == account.id, + isCurrent: account.isCurrent || isCurrentAccount(account) + ) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard indexPath.row < viewModel.accounts.count else { return } + viewModel.select(viewModel.accounts[indexPath.row]) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + viewModel.accounts.isEmpty ? 280 : 92 + } +} + +private final class AccountSwitchCell: UITableViewCell { + static let reuseID = "AccountSwitchCell" + + private let avatarView = UIImageView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let phoneLabel = UILabel() + private let tagLabel = UILabel() + private let checkView = UIImageView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + backgroundColor = .clear + + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 12 + contentView.addSubview(card) + + avatarView.layer.cornerRadius = 25 + avatarView.clipsToBounds = true + avatarView.contentMode = .scaleAspectFill + + titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + subtitleLabel.font = .systemFont(ofSize: 13) + subtitleLabel.textColor = AppDesignUIKit.textSecondary + phoneLabel.font = .systemFont(ofSize: 12) + phoneLabel.textColor = UIColor(hex: 0x9AA1AA) + tagLabel.font = .systemFont(ofSize: 11, weight: .semibold) + tagLabel.textAlignment = .center + tagLabel.layer.cornerRadius = 4 + tagLabel.clipsToBounds = true + + card.addSubview(avatarView) + card.addSubview(titleLabel) + card.addSubview(subtitleLabel) + card.addSubview(phoneLabel) + card.addSubview(tagLabel) + card.addSubview(checkView) + + card.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16)) + } + avatarView.snp.makeConstraints { make in + make.leading.centerY.equalToSuperview().inset(14) + make.width.height.equalTo(50) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarView.snp.trailing).offset(13) + make.top.equalTo(avatarView).offset(2) + make.trailing.lessThanOrEqualTo(tagLabel.snp.leading).offset(-8) + } + subtitleLabel.snp.makeConstraints { make in + make.leading.equalTo(titleLabel) + make.top.equalTo(titleLabel.snp.bottom).offset(4) + } + phoneLabel.snp.makeConstraints { make in + make.leading.equalTo(titleLabel) + make.top.equalTo(subtitleLabel.snp.bottom).offset(4) + } + tagLabel.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(14) + make.top.equalToSuperview().offset(14) + make.height.equalTo(22) + make.width.greaterThanOrEqualTo(40) + } + checkView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(14) + make.bottom.equalToSuperview().inset(14) + make.width.height.equalTo(22) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(account: AccountSwitchAccount, selected: Bool, isCurrent: Bool) { + let title = account.title.trimmingCharacters(in: .whitespacesAndNewlines) + titleLabel.text = title.isEmpty ? account.accountTypeLabel : title + subtitleLabel.text = account.subtitle + phoneLabel.text = account.phone + tagLabel.text = account.isStoreUser ? "门店" : "景区" + tagLabel.textColor = account.isStoreUser ? UIColor(hex: 0x0F9F6E) : UIColor(hex: 0x7C3AED) + tagLabel.backgroundColor = account.isStoreUser ? UIColor(hex: 0xE8F8F1) : UIColor(hex: 0xF3ECFF) + checkView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") + checkView.tintColor = selected ? AppDesignUIKit.primary : UIColor(hex: 0xB6BECA) + avatarView.loadRemoteAvatar(urlString: account.avatar) + if isCurrent { + titleLabel.text = (titleLabel.text ?? "") + " (当前)" + } + } +} diff --git a/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift new file mode 100644 index 0000000..99043cf --- /dev/null +++ b/suixinkan_ios/Features/Profile/ViewControllers/ProfileViewController.swift @@ -0,0 +1,278 @@ +// +// ProfileViewController.swift +// suixinkan +// + +import PhotosUI +import SnapKit +import UIKit + +/// 个人信息页,展示用户资料、账号状态和设置入口。 +final class ProfileViewController: UIViewController { + + private let viewModel = ProfileViewModel() + + private let avatarImageView = UIImageView() + private let nicknameLabel = UILabel() + private let uidLabel = UILabel() + private let editButton = UIButton(type: .system) + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.dataSource = self + table.delegate = self + return table + }() + + private lazy var refreshControl = UIRefreshControl() + + override func viewDidLoad() { + super.viewDidLoad() + title = "个人信息" + view.backgroundColor = UIColor(hex: 0xF7FAFF) + setupHeader() + view.addSubview(tableView) + tableView.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(150) + make.leading.trailing.bottom.equalToSuperview() + } + refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) + tableView.refreshControl = refreshControl + + viewModel.onChange = { [weak self] in self?.applyViewModel() } + editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside) + + let avatarTap = UITapGestureRecognizer(target: self, action: #selector(pickAvatar)) + avatarImageView.isUserInteractionEnabled = true + avatarImageView.addGestureRecognizer(avatarTap) + + Task { await reloadProfile(showToast: false) } + } + + private func setupHeader() { + let header = UIView() + header.backgroundColor = .white + header.layer.cornerRadius = 17 + view.addSubview(header) + + avatarImageView.layer.cornerRadius = 43 + avatarImageView.clipsToBounds = true + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.backgroundColor = AppDesignUIKit.primarySoft + + nicknameLabel.font = .systemFont(ofSize: 24, weight: .semibold) + nicknameLabel.textColor = UIColor(hex: 0x252525) + uidLabel.font = .systemFont(ofSize: 18, weight: .semibold) + uidLabel.textColor = UIColor(hex: 0x606A7A) + + editButton.setImage(UIImage(systemName: "pencil"), for: .normal) + editButton.tintColor = .white + editButton.backgroundColor = AppDesignUIKit.primary + editButton.layer.cornerRadius = 22 + + header.addSubview(avatarImageView) + header.addSubview(nicknameLabel) + header.addSubview(uidLabel) + header.addSubview(editButton) + + header.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(16) + make.leading.trailing.equalToSuperview().inset(19) + make.height.equalTo(132) + } + avatarImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(18) + make.centerY.equalToSuperview() + make.width.height.equalTo(86) + } + nicknameLabel.snp.makeConstraints { make in + make.leading.equalTo(avatarImageView.snp.trailing).offset(14) + make.trailing.lessThanOrEqualTo(editButton.snp.leading).offset(-8) + make.top.equalTo(avatarImageView).offset(8) + } + uidLabel.snp.makeConstraints { make in + make.leading.equalTo(nicknameLabel) + make.top.equalTo(nicknameLabel.snp.bottom).offset(8) + } + editButton.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(18) + make.centerY.equalToSuperview() + make.width.height.equalTo(44) + } + } + + private func applyViewModel() { + nicknameLabel.text = viewModel.displayNickname + uidLabel.text = "UID: \(appServices.accountContext.profile?.userId ?? "--")" + if let data = viewModel.pendingAvatarData, let image = UIImage(data: data) { + avatarImageView.image = image + } else { + avatarImageView.loadRemoteAvatar(urlString: viewModel.displayAvatarURL) + } + editButton.setImage(UIImage(systemName: viewModel.isEditingProfile ? "checkmark" : "pencil"), for: .normal) + editButton.isEnabled = !viewModel.isSaving + tableView.reloadData() + } + + @objc private func refreshPulled() { + Task { + await reloadProfile(showToast: true) + refreshControl.endRefreshing() + } + } + + private func reloadProfile(showToast: Bool) async { + do { + try await appServices.globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载资料...") { + try await self.viewModel.reload(api: self.appServices.profileAPI) + } + } catch { + if showToast { self.showToast(error.localizedDescription) } + } + } + + @objc private func editTapped() { + if viewModel.isEditingProfile { + Task { await saveProfileEdits() } + } else { + viewModel.beginEditing() + presentNicknameEditor() + } + } + + private func presentNicknameEditor() { + let alert = UIAlertController(title: "编辑昵称", message: nil, preferredStyle: .alert) + alert.addTextField { [weak self] field in + field.text = self?.viewModel.editingNickname + field.placeholder = "请输入昵称" + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in + self?.viewModel.cancelEditing() + }) + alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self] _ in + guard let self else { return } + self.viewModel.editingNickname = alert.textFields?.first?.text ?? "" + Task { await self.saveProfileEdits() } + }) + present(alert, animated: true) + } + + private func saveProfileEdits() async { + guard let scenicId = appServices.accountContext.currentScenic?.id else { + showToast("请先选择景区") + return + } + do { + try await appServices.globalLoading.withLoading(message: "保存中...") { + try await self.viewModel.saveProfile( + api: self.appServices.profileAPI, + uploader: self.appServices.ossUploadService, + scenicId: scenicId + ) + } + showToast("资料已更新") + } catch { + showToast(error.localizedDescription) + } + } + + @objc private func pickAvatar() { + var config = PHPickerConfiguration() + config.filter = .images + config.selectionLimit = 1 + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + present(picker, animated: true) + } + + @objc private func logoutTapped() { + let alert = UIAlertController(title: "确认退出当前账号?", message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "退出登录", style: .destructive) { [weak self] _ in + guard let self else { return } + self.appServices.authSessionCoordinator.logout( + appSession: self.appServices.appSession, + accountContext: self.appServices.accountContext, + permissionContext: self.appServices.permissionContext, + scenicSpotContext: self.appServices.scenicSpotContext, + appRouter: self.appServices.appRouter, + toastCenter: self.appServices.toastCenter + ) + }) + present(alert, animated: true) + } +} + +extension ProfileViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { 2 } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? 5 : 1 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + if indexPath.section == 1 { + cell.textLabel?.text = "退出登录" + cell.textLabel?.textColor = UIColor(hex: 0xEF4444) + cell.textLabel?.textAlignment = .center + return cell + } + switch indexPath.row { + case 0: + cell.textLabel?.text = "姓名" + cell.detailTextLabel?.text = viewModel.displayRealName + case 1: + cell.textLabel?.text = "当前账号" + cell.detailTextLabel?.text = appServices.accountContext.profile?.displayName ?? "--" + cell.accessoryType = .disclosureIndicator + case 2: + cell.textLabel?.text = "手机号" + cell.detailTextLabel?.text = viewModel.displayPhone + case 3: + cell.textLabel?.text = "实名认证" + cell.detailTextLabel?.text = viewModel.realNameStatusText + cell.accessoryType = .disclosureIndicator + default: + cell.textLabel?.text = "设置" + cell.accessoryType = .disclosureIndicator + } + cell.selectionStyle = indexPath.row == 0 || indexPath.row == 2 ? .none : .default + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if indexPath.section == 1 { + logoutTapped() + return + } + switch indexPath.row { + case 1: + HomeMenuRouting.pushProfile(.accountSwitch, from: self) + case 3: + HomeMenuRouting.pushProfile(.realNameAuth, from: self) + case 4: + HomeMenuRouting.pushProfile(.settings, from: self) + default: + break + } + } +} + +extension ProfileViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } + provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in + guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return } + DispatchQueue.main.async { + do { + try self.viewModel.prepareAvatarImage(data: data) + } catch { + self.showToast(error.localizedDescription) + } + } + } + } +} diff --git a/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift b/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift new file mode 100644 index 0000000..5b16955 --- /dev/null +++ b/suixinkan_ios/Features/Profile/ViewControllers/RealNameAuthViewController.swift @@ -0,0 +1,261 @@ +// +// RealNameAuthViewController.swift +// suixinkan +// + +import PhotosUI +import SnapKit +import UIKit + +/// 实名认证页面,展示审核状态并允许提交基础实名资料。 +final class RealNameAuthViewController: UIViewController { + + private let viewModel = RealNameAuthViewModel() + + private let scrollView = UIScrollView() + private let contentStack = UIStackView() + + private let realNameField = UITextField() + private let idCardField = UITextField() + private let smsCodeField = UITextField() + private let frontImageView = UIImageView() + private let backImageView = UIImageView() + private let statusLabel = UILabel() + private let longValidSwitch = UISwitch() + private let startDatePicker = UIDatePicker() + private let endDatePicker = UIDatePicker() + + override func viewDidLoad() { + super.viewDidLoad() + title = "实名认证" + view.backgroundColor = AppDesignUIKit.pageBackground + setupForm() + viewModel.onChange = { [weak self] in self?.applyViewModel() } + Task { await loadInfo() } + } + + private func setupForm() { + contentStack.axis = .vertical + contentStack.spacing = 16 + view.addSubview(scrollView) + scrollView.addSubview(contentStack) + scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() } + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + make.width.equalTo(scrollView).offset(-32) + } + + [realNameField, idCardField, smsCodeField].forEach { + $0.borderStyle = .roundedRect + $0.font = .systemFont(ofSize: 16) + } + realNameField.placeholder = "请输入姓名" + idCardField.placeholder = "请输入身份证号码" + smsCodeField.placeholder = "请输入短信验证码" + smsCodeField.keyboardType = .numberPad + + [frontImageView, backImageView].forEach { + $0.backgroundColor = UIColor(hex: 0xF8FAFC) + $0.contentMode = .scaleAspectFit + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + $0.isUserInteractionEnabled = true + $0.snp.makeConstraints { make in make.height.equalTo(160) } + } + frontImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickFront))) + backImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(pickBack))) + + startDatePicker.datePickerMode = .date + endDatePicker.datePickerMode = .date + if #available(iOS 17.0, *) { + startDatePicker.preferredDatePickerStyle = .compact + endDatePicker.preferredDatePickerStyle = .compact + } + + let sendCodeButton = UIButton(type: .system) + sendCodeButton.setTitle("获取验证码", for: .normal) + sendCodeButton.addTarget(self, action: #selector(sendCodeTapped), for: .touchUpInside) + + let submitButton = makePrimaryButton(title: "下一步") + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + + statusLabel.numberOfLines = 0 + statusLabel.font = .systemFont(ofSize: 13) + statusLabel.textColor = AppDesignUIKit.textSecondary + + contentStack.addArrangedSubview(makeSectionTitle("审核状态")) + contentStack.addArrangedSubview(statusLabel) + contentStack.addArrangedSubview(makeSectionTitle("身份证国徽面")) + contentStack.addArrangedSubview(backImageView) + contentStack.addArrangedSubview(makeSectionTitle("身份证人像面")) + contentStack.addArrangedSubview(frontImageView) + contentStack.addArrangedSubview(labeledField("姓名", realNameField)) + contentStack.addArrangedSubview(labeledField("身份证号码", idCardField)) + + let longValidRow = UIStackView(arrangedSubviews: [UILabel(text: "长期有效"), longValidSwitch]) + longValidRow.axis = .horizontal + longValidSwitch.addTarget(self, action: #selector(longValidChanged), for: .valueChanged) + contentStack.addArrangedSubview(longValidRow) + contentStack.addArrangedSubview(startDatePicker) + contentStack.addArrangedSubview(endDatePicker) + + let smsRow = UIStackView(arrangedSubviews: [smsCodeField, sendCodeButton]) + smsRow.axis = .horizontal + smsRow.spacing = 8 + smsCodeField.snp.makeConstraints { make in make.height.equalTo(44) } + contentStack.addArrangedSubview(smsRow) + contentStack.addArrangedSubview(submitButton) + } + + private func makeSectionTitle(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = .systemFont(ofSize: 17, weight: .semibold) + return label + } + + private func labeledField(_ title: String, _ field: UITextField) -> UIStackView { + let label = UILabel() + label.text = title + label.font = .systemFont(ofSize: 15, weight: .medium) + let stack = UIStackView(arrangedSubviews: [label, field]) + stack.axis = .vertical + stack.spacing = 8 + field.snp.makeConstraints { make in make.height.equalTo(44) } + return stack + } + + private func applyViewModel() { + realNameField.text = viewModel.realName + idCardField.text = viewModel.idCardNo + smsCodeField.text = viewModel.smsCode + longValidSwitch.isOn = viewModel.isLongValid + startDatePicker.date = viewModel.startDate + endDatePicker.date = viewModel.endDate + endDatePicker.isEnabled = !viewModel.isLongValid + + if let data = viewModel.pendingFrontImageData, let image = UIImage(data: data) { + frontImageView.image = image + } else if !viewModel.frontUrl.isEmpty { + frontImageView.loadRemoteImage(urlString: viewModel.frontUrl, contentMode: .scaleAspectFit) + } + + if let data = viewModel.pendingBackImageData, let image = UIImage(data: data) { + backImageView.image = image + } else if !viewModel.backUrl.isEmpty { + backImageView.image = nil + backImageView.loadRemoteImage(urlString: viewModel.backUrl, contentMode: .scaleAspectFit) + } + + if let info = viewModel.info { + statusLabel.text = """ + 审核状态:\(viewModel.auditStatusText(info.auditStatus)) + 审核人:\(info.auditor?.name ?? "--") + 审核时间:\(info.auditAt ?? "--") + 审核备注:\(info.rejectReason ?? "--") + """ + } else { + statusLabel.text = "尚未提交实名认证" + } + + if let message = viewModel.statusMessage { + showToast(message) + } + } + + private func loadInfo() async { + do { + try await appServices.globalLoading.withLoading(message: "加载中...") { + try await self.viewModel.load(api: self.appServices.profileAPI) + } + } catch { + showToast(error.localizedDescription) + } + } + + @objc private func longValidChanged() { + viewModel.isLongValid = longValidSwitch.isOn + } + + @objc private func sendCodeTapped() { + Task { + do { + try await viewModel.sendCode(api: appServices.profileAPI) + } catch { + showToast(error.localizedDescription) + } + } + } + + @objc private func submitTapped() { + viewModel.realName = realNameField.text ?? "" + viewModel.idCardNo = idCardField.text ?? "" + viewModel.smsCode = smsCodeField.text ?? "" + viewModel.startDate = startDatePicker.date + viewModel.endDate = endDatePicker.date + + guard let scenicId = appServices.accountContext.currentScenic?.id else { + showToast("请先选择景区") + return + } + Task { + do { + try await appServices.globalLoading.withLoading(message: "提交中...") { + try await self.viewModel.submit( + api: self.appServices.profileAPI, + uploader: self.appServices.ossUploadService, + scenicId: scenicId + ) + } + } catch { + showToast(error.localizedDescription) + } + } + } + + private var pendingSide: RealNameImageSide = .front + + @objc private func pickFront() { + pendingSide = .front + presentImagePicker() + } + + @objc private func pickBack() { + pendingSide = .back + presentImagePicker() + } + + private func presentImagePicker() { + var config = PHPickerConfiguration() + config.filter = .images + config.selectionLimit = 1 + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + present(picker, animated: true) + } +} + +extension RealNameAuthViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } + provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in + guard let self, let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return } + DispatchQueue.main.async { + do { + try self.viewModel.prepareIdentityImage(data: data, side: self.pendingSide) + } catch { + self.showToast(error.localizedDescription) + } + } + } + } +} + +private extension UILabel { + convenience init(text: String) { + self.init() + self.text = text + self.font = .systemFont(ofSize: 16, weight: .semibold) + } +} diff --git a/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift b/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift new file mode 100644 index 0000000..6082e19 --- /dev/null +++ b/suixinkan_ios/Features/Profile/ViewControllers/SettingsViewControllers.swift @@ -0,0 +1,135 @@ +// +// SettingsViewControllers.swift +// suixinkan +// + +import SnapKit +import UIKit +import WebKit + +/// 设置中心页面。 +final class SettingsViewController: UIViewController { + + private var copiedDownloadLink = false + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .insetGrouped) + table.dataSource = self + table.delegate = self + return table + }() + + private let rows: [(title: String, action: SettingsRowAction)] = [ + ("关于我们", .agreement(.about)), + ("系统版本", .version), + ("App下载", .download), + ("用户协议", .agreement(.userAgreement)), + ("隐私政策", .agreement(.privacyPolicy)) + ] + + private enum SettingsRowAction { + case agreement(AgreementPage) + case version + case download + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "设置" + view.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + } + + private var downloadLink: String { + APIEnvironment.current.baseURL.appending(path: "/h5/app/download").absoluteString + } + + private func copyDownloadLink() { + UIPasteboard.general.string = downloadLink + copiedDownloadLink = true + showToast("下载链接已复制") + tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.copiedDownloadLink = false + self?.tableView.reloadRows(at: [IndexPath(row: 2, section: 0)], with: .none) + } + } +} + +extension SettingsViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + let row = rows[indexPath.row] + cell.textLabel?.text = row.title + cell.textLabel?.textColor = UIColor(hex: 0x4B5563) + switch row.action { + case .version: + cell.detailTextLabel?.text = SettingsDisplayPolicy.versionText() + cell.selectionStyle = .none + case .download: + cell.detailTextLabel?.text = copiedDownloadLink ? "已复制" : "复制链接" + cell.detailTextLabel?.textColor = AppDesignUIKit.primary + case .agreement: + cell.accessoryType = .disclosureIndicator + } + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + switch rows[indexPath.row].action { + case .agreement(let page): + HomeMenuRouting.pushProfile(.agreement(page), from: self) + case .download: + copyDownloadLink() + case .version: + break + } + } + + func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号" + } +} + +/// 协议 H5 页面。 +final class AgreementViewController: UIViewController { + + private let page: AgreementPage + private let webView = WKWebView(frame: .zero) + private let loadingIndicator = UIActivityIndicatorView(style: .large) + + init(page: AgreementPage) { + self.page = page + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + title = page.title + view.backgroundColor = AppDesignUIKit.pageBackground + webView.navigationDelegate = self + view.addSubview(webView) + view.addSubview(loadingIndicator) + webView.snp.makeConstraints { make in make.edges.equalToSuperview() } + loadingIndicator.snp.makeConstraints { make in make.center.equalToSuperview() } + loadingIndicator.startAnimating() + webView.load(URLRequest(url: page.url)) + } +} + +extension AgreementViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + loadingIndicator.stopAnimating() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + loadingIndicator.stopAnimating() + showToast(error.localizedDescription) + } +} diff --git a/suixinkan_ios/Features/Profile/ViewModels/AccountSwitchViewModel.swift b/suixinkan_ios/Features/Profile/ViewModels/AccountSwitchViewModel.swift new file mode 100644 index 0000000..3c5e17a --- /dev/null +++ b/suixinkan_ios/Features/Profile/ViewModels/AccountSwitchViewModel.swift @@ -0,0 +1,77 @@ +// +// AccountSwitchViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 账号切换 ViewModel,管理可切换账号列表、选择状态和提交状态。 +final class AccountSwitchViewModel { + var onChange: (() -> Void)? + private(set) var accounts: [AccountSwitchAccount] = [] { didSet { onChange?() } } + var selectedAccountId: String? { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var switching = false { didSet { onChange?() } } + private var didLoad = false + + /// 当前选中的账号。 + var selectedAccount: AccountSwitchAccount? { + accounts.first { $0.id == selectedAccountId } + } + + /// 拉取可切换账号列表,默认只加载一次。 + func load(api: ProfileAPI, force: Bool = false, currentAccountId: String? = nil) async throws { + guard force || !didLoad else { return } + didLoad = true + loading = true + defer { loading = false } + + let response = try await api.switchableAccounts() + accounts = response.accounts.filter { $0.businessUserId > 0 } + selectedAccountId = accounts.first(where: { $0.isCurrent || isCurrent($0, currentAccountId: currentAccountId) })?.id + ?? accounts.first?.id + } + + /// 选择指定账号。 + func select(_ account: AccountSwitchAccount) { + selectedAccountId = account.id + } + + /// 提交账号切换,调用 set-user 换取正式 token。 + func switchAccount(_ account: AccountSwitchAccount, api: AuthAPI) async throws -> V9AuthResponse { + guard !switching else { + throw AccountSwitchError.submitting + } + guard account.businessUserId > 0 else { + throw AccountSwitchError.invalidAccount + } + + switching = true + defer { switching = false } + return try await api.setUser(account.toSetUserRequest()) + } + + /// 判断账号是否与当前账号标识一致。 + func isCurrent(_ account: AccountSwitchAccount, currentAccountId: String?) -> Bool { + guard let currentAccountId else { return false } + return account.id == currentAccountId || String(account.businessUserId) == currentAccountId + } +} + +/// 账号切换错误实体,表示重复提交或账号数据异常。 +enum AccountSwitchError: LocalizedError, Equatable { + case submitting + case invalidAccount + + var errorDescription: String? { + switch self { + case .submitting: + "账号正在切换,请稍候" + case .invalidAccount: + "账号信息异常,请重新选择账号" + } + } +} diff --git a/suixinkan_ios/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan_ios/Features/Profile/ViewModels/ProfileViewModel.swift new file mode 100644 index 0000000..38ac0a3 --- /dev/null +++ b/suixinkan_ios/Features/Profile/ViewModels/ProfileViewModel.swift @@ -0,0 +1,200 @@ +// +// ProfileViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/20. +// + +import Foundation + +@MainActor +/// 个人信息页 ViewModel,负责资料加载、编辑状态和提交更新。 +final class ProfileViewModel { + var onChange: (() -> Void)? + var userInfo: UserInfoResponse? { didSet { onChange?() } } + var realNameInfo: RealNameInfo? { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isSaving = false { didSet { onChange?() } } + var isEditingProfile = false { didSet { onChange?() } } + var editingNickname = "" { didSet { onChange?() } } + private(set) var pendingAvatarData: Data? { didSet { onChange?() } } + private(set) var pendingAvatarFileName: String? { didSet { onChange?() } } + private(set) var avatarUploadProgress: Int? { didSet { onChange?() } } + + 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 + clearPendingAvatar() + } + + /// 准备待上传头像,并进入资料编辑状态。 + func prepareAvatarImage(data: Data, timestamp: TimeInterval = Date().timeIntervalSince1970) throws { + let processed = try AvatarImageProcessor.process(data: data, timestamp: timestamp) + pendingAvatarData = processed.data + pendingAvatarFileName = processed.fileName + if !isEditingProfile { + beginEditing() + } + } + + /// 保存昵称和头像修改,并同步更新本地 userInfo。 + func saveProfile(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws { + let nextNickname = editingNickname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !nextNickname.isEmpty else { + throw ProfileValidationError.emptyNickname + } + + let nicknameChanged = nextNickname != displayNickname + let avatarChanged = pendingAvatarData != nil + guard nicknameChanged || avatarChanged else { + cancelEditing() + return + } + + guard !isSaving else { return } + isSaving = true + avatarUploadProgress = avatarChanged ? 1 : nil + defer { + isSaving = false + avatarUploadProgress = nil + } + + let uploadedAvatarURL = try await uploadPendingAvatarIfNeeded(uploader: uploader, scenicId: scenicId) + if let uploadedAvatarURL { + try await api.updateUserAvatarURL(uploadedAvatarURL) + } + if nicknameChanged { + try await api.updateUserInfo(nickname: nextNickname) + } + var nextUser = userInfo ?? UserInfoResponse() + nextUser = UserInfoResponse( + avatar: uploadedAvatarURL ?? nextUser.avatar, + realName: nextUser.realName, + phone: nextUser.phone, + nickname: nextNickname, + roleName: nextUser.roleName, + status: nextUser.status, + statusName: nextUser.statusName + ) + userInfo = nextUser + clearPendingAvatar() + 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 + } + + /// 上传待保存头像;没有待上传图片时返回 nil。 + private func uploadPendingAvatarIfNeeded(uploader: any OSSUploadServing, scenicId: Int) async throws -> String? { + guard let pendingAvatarData else { return nil } + let fileName = pendingAvatarFileName ?? "avatar_\(Int(Date().timeIntervalSince1970)).jpg" + return try await uploader.uploadUserAvatar(data: pendingAvatarData, fileName: fileName, scenicId: scenicId) { [weak self] progress in + Task { @MainActor in + self?.avatarUploadProgress = progress + } + } + } + + /// 清空本地待上传头像数据。 + private func clearPendingAvatar() { + pendingAvatarData = nil + pendingAvatarFileName = nil + avatarUploadProgress = nil + } +} + +/// 个人信息页校验错误实体,表示昵称和密码输入不符合要求。 +enum ProfileValidationError: LocalizedError { + case emptyNickname + case shortPassword + + var errorDescription: String? { + switch self { + case .emptyNickname: + "请输入昵称" + case .shortPassword: + "密码至少 6 位" + } + } +} diff --git a/suixinkan_ios/Features/Profile/ViewModels/RealNameAuthViewModel.swift b/suixinkan_ios/Features/Profile/ViewModels/RealNameAuthViewModel.swift new file mode 100644 index 0000000..c7ee94d --- /dev/null +++ b/suixinkan_ios/Features/Profile/ViewModels/RealNameAuthViewModel.swift @@ -0,0 +1,230 @@ +// +// RealNameAuthViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 实名认证 ViewModel,管理认证资料表单、审核状态、短信验证码和提交状态。 +final class RealNameAuthViewModel { + var onChange: (() -> Void)? + private(set) var info: RealNameInfo? { didSet { onChange?() } } + var realName = "" { didSet { onChange?() } } + var idCardNo = "" { didSet { onChange?() } } + var smsCode = "" { didSet { onChange?() } } + var startDate = Date() { didSet { onChange?() } } + var endDate = Calendar.current.date(byAdding: .year, value: 10, to: Date()) ?? Date() { didSet { onChange?() } } + var isLongValid = false { didSet { onChange?() } } + var frontUrl = "" { didSet { onChange?() } } + var backUrl = "" { didSet { onChange?() } } + private(set) var pendingFrontImageData: Data? { didSet { onChange?() } } + private(set) var pendingBackImageData: Data? { didSet { onChange?() } } + private(set) var frontUploadProgress: Int? { didSet { onChange?() } } + private(set) var backUploadProgress: Int? { didSet { onChange?() } } + private var pendingFrontFileName: String? + private var pendingBackFileName: String? + private(set) var loading = false { didSet { onChange?() } } + private(set) var sendingCode = false { didSet { onChange?() } } + private(set) var submitting = false { didSet { onChange?() } } + var statusMessage: String? { didSet { onChange?() } } + + /// 拉取实名认证信息,并回填表单。 + func load(api: ProfileAPI) async throws { + guard !loading else { return } + loading = true + defer { loading = false } + + let response = try await api.realNameInfo() + apply(response.realNameInfo) + } + + /// 发送短信验证码。 + func sendCode(api: ProfileAPI) async throws { + guard !sendingCode else { return } + sendingCode = true + defer { sendingCode = false } + + try await api.realNameSmsVerifyCode() + statusMessage = "验证码已发送" + } + + /// 准备待上传证件图片,并更新对应面的本地预览。 + func prepareIdentityImage(data: Data, side: RealNameImageSide, timestamp: TimeInterval = Date().timeIntervalSince1970) throws { + let processed = try RealNameImageProcessor.process(data: data, side: side, timestamp: timestamp) + switch side { + case .front: + pendingFrontImageData = processed.data + pendingFrontFileName = processed.fileName + frontUrl = "" + case .back: + pendingBackImageData = processed.data + pendingBackFileName = processed.fileName + backUrl = "" + } + } + + /// 提交实名认证资料,成功后刷新审核状态。 + func submit(api: ProfileAPI, uploader: any OSSUploadServing, scenicId: Int) async throws { + if info?.verified == true { + statusMessage = "已完成实名认证" + return + } + if let validationMessage { + throw RealNameValidationError.message(validationMessage) + } + + submitting = true + defer { submitting = false } + try await uploadPendingIdentityImages(uploader: uploader, scenicId: scenicId) + try await api.realNameSubmit(makeRequest()) + statusMessage = "已提交审核" + try await load(api: api) + } + + /// 根据审核状态生成展示文案。 + func auditStatusText(_ status: Int) -> String { + switch status { + case 1: "待审核" + case 2: "审核通过" + case 3: "审核不通过" + default: "未认证" + } + } + + /// 当前表单校验错误,nil 表示可以提交。 + var validationMessage: String? { + if realName.trimmed.isEmpty { return "请输入真实姓名" } + if idCardNo.trimmed.isEmpty { return "请输入身份证号" } + if !Self.isValidMainlandIDCardNumber(idCardNo) { return "请输入有效的身份证号" } + if info?.verified != true { + if smsCode.trimmed.isEmpty { return "请输入短信验证码" } + if frontUrl.trimmed.isEmpty && pendingFrontImageData == nil { return "请选择身份证人像面图片" } + if backUrl.trimmed.isEmpty && pendingBackImageData == nil { return "请选择身份证国徽面图片" } + } + if !isLongValid && endDate < startDate { return "证件结束日期不能早于起始日期" } + return nil + } + + /// 应用接口返回的实名认证信息。 + private func apply(_ info: RealNameInfo?) { + self.info = info + guard let info else { return } + realName = info.realName + idCardNo = info.idCardNo + frontUrl = info.frontUrl ?? "" + backUrl = info.backUrl ?? "" + pendingFrontImageData = nil + pendingBackImageData = nil + pendingFrontFileName = nil + pendingBackFileName = nil + isLongValid = info.isLongValid + startDate = Self.date(from: info.startDate) ?? startDate + endDate = Self.date(from: info.endDate) ?? endDate + } + + /// 生成提交请求。 + private func makeRequest() -> RealNameAuthRequest { + RealNameAuthRequest( + realName: realName.trimmed, + idCardNo: Self.normalizedIDCardNumber(idCardNo), + smsVerifyCode: smsCode.trimmed, + startDate: Self.dateFormatter.string(from: startDate), + endDate: isLongValid ? "" : Self.dateFormatter.string(from: endDate), + isLongValid: isLongValid ? 1 : 0, + frontUrl: frontUrl.trimmed, + backUrl: backUrl.trimmed + ) + } + + /// 规范化身份证号,去除空白并大写末位 X。 + nonisolated static func normalizedIDCardNumber(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + } + + /// 校验大陆身份证号基础格式和校验位。 + nonisolated static func isValidMainlandIDCardNumber(_ value: String) -> Bool { + let number = normalizedIDCardNumber(value) + guard number.count == 18 else { return false } + let chars = Array(number) + guard chars.prefix(17).allSatisfy(\.isNumber) else { return false } + guard chars[17].isNumber || chars[17] == "X" else { return false } + + let weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] + let checkCodes = Array("10X98765432") + let sum = zip(chars.prefix(17), weights).reduce(0) { partial, pair in + partial + (pair.0.wholeNumberValue ?? 0) * pair.1 + } + return chars[17] == checkCodes[sum % 11] + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + /// 将 yyyy-MM-dd 文本解析为日期。 + private static func date(from value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return dateFormatter.date(from: value) + } + + /// 上传待提交证件图片,并把返回 URL 写回表单字段。 + private func uploadPendingIdentityImages(uploader: any OSSUploadServing, scenicId: Int) async throws { + if let pendingFrontImageData { + frontUploadProgress = 1 + defer { frontUploadProgress = nil } + frontUrl = try await uploader.uploadRealNameImage( + data: pendingFrontImageData, + fileName: pendingFrontFileName ?? "real_name_front_\(Int(Date().timeIntervalSince1970)).jpg", + scenicId: scenicId + ) { [weak self] progress in + Task { @MainActor in + self?.frontUploadProgress = progress + } + } + self.pendingFrontImageData = nil + pendingFrontFileName = nil + } + + if let pendingBackImageData { + backUploadProgress = 1 + defer { backUploadProgress = nil } + backUrl = try await uploader.uploadRealNameImage( + data: pendingBackImageData, + fileName: pendingBackFileName ?? "real_name_back_\(Int(Date().timeIntervalSince1970)).jpg", + scenicId: scenicId + ) { [weak self] progress in + Task { @MainActor in + self?.backUploadProgress = progress + } + } + self.pendingBackImageData = nil + pendingBackFileName = nil + } + } +} + +/// 实名认证校验错误实体,用于把表单错误统一抛给页面展示。 +enum RealNameValidationError: LocalizedError, Equatable { + case message(String) + + var errorDescription: String? { + switch self { + case .message(let message): + message + } + } +} + +private extension String { + /// 去除首尾空白后的文本。 + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/suixinkan_ios/Features/Projects/API/ProjectAPI.swift b/suixinkan_ios/Features/Projects/API/ProjectAPI.swift new file mode 100644 index 0000000..65653f4 --- /dev/null +++ b/suixinkan_ios/Features/Projects/API/ProjectAPI.swift @@ -0,0 +1,176 @@ +// +// ProjectAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 项目服务协议,抽象摄影师项目和店铺项目接口以便 ViewModel 测试替换。 +@MainActor +protocol ProjectServing { + /// 获取摄影师项目列表。 + func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload + + /// 获取摄影师项目详情。 + func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse + + /// 创建摄影师项目。 + func createProject(_ request: ProjectCreateRequest) async throws + + /// 编辑摄影师项目。 + func editProject(_ request: ProjectCreateRequest) async throws + + /// 删除摄影师项目。 + func deleteProject(id: Int) async throws + + /// 获取店铺项目可管理景区。 + func storeManagerScenicList(userId: String) async throws -> ListPayload + + /// 获取店铺项目列表。 + func storeManagerProjectList(userId: String?, page: Int, pageSize: Int) async throws -> ListPayload + + /// 获取店铺项目详情。 + func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse + + /// 创建店铺多点位项目。 + func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws + + /// 创建店铺押金项目。 + func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws + + /// 更新店铺多点位项目。 + func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws + + /// 更新店铺押金项目。 + func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws + + /// 删除店铺项目。 + func storeManagerDeleteProject(id: Int) async throws + + /// 获取全部门店,用于店铺押金项目选择门店。 + func storeAll() async throws -> ListPayload +} + +/// 项目 API,封装项目管理、店铺项目管理相关接口。 +@MainActor +final class ProjectAPI: ProjectServing { + private let client: APIClient + + /// 初始化项目 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取摄影师项目列表。 + func projectList(scenicId: Int, name: String? = nil, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { + query.append(URLQueryItem(name: "name", value: name)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/project/list", queryItems: query) + ) + } + + /// 获取摄影师项目详情。 + func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/project/info-view", + queryItems: [URLQueryItem(name: "id", value: "\(id)")] + ) + ) + } + + /// 创建摄影师项目。 + func createProject(_ request: ProjectCreateRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/project/create", body: request) + ) as EmptyPayload + } + + /// 编辑摄影师项目。 + func editProject(_ request: ProjectCreateRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/project/edit", body: request) + ) as EmptyPayload + } + + /// 删除摄影师项目。 + func deleteProject(id: Int) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/project/delete", body: ProjectDeleteRequest(id: id)) + ) as EmptyPayload + } + + /// 获取店铺项目可管理景区。 + func storeManagerScenicList(userId: String) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/store-manager/scenic-list", + queryItems: [URLQueryItem(name: "user_id", value: userId)] + ) + ) + } + + /// 获取店铺项目列表。 + func storeManagerProjectList(userId: String?, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if let userId = userId?.trimmingCharacters(in: .whitespacesAndNewlines), !userId.isEmpty { + query.append(URLQueryItem(name: "user_id", value: userId)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/app/store-manager/list", queryItems: query) + ) + } + + /// 获取店铺项目详情。 + func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { + try await client.send( + APIRequest(method: .get, path: "/api/app/store-manager/detail", queryItems: [URLQueryItem(name: "id", value: "\(id)")]) + ) + } + + /// 创建店铺多点位项目。 + func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws { + _ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/create", body: request)) as EmptyPayload + } + + /// 创建店铺押金项目。 + func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws { + _ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/offline-create", body: request)) as EmptyPayload + } + + /// 更新店铺多点位项目。 + func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws { + _ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/update", body: request)) as EmptyPayload + } + + /// 更新店铺押金项目。 + func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws { + _ = try await client.send(APIRequest(method: .post, path: "/api/app/store-manager/offline-update", body: request)) as EmptyPayload + } + + /// 删除店铺项目。 + func storeManagerDeleteProject(id: Int) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/app/store-manager/delete", body: ProjectDeleteRequest(id: id)) + ) as EmptyPayload + } + + /// 获取全部门店。 + func storeAll() async throws -> ListPayload { + try await client.send(APIRequest(method: .get, path: "/api/app/store/all")) + } +} diff --git a/suixinkan_ios/Features/Projects/Models/ProjectModels.swift b/suixinkan_ios/Features/Projects/Models/ProjectModels.swift new file mode 100644 index 0000000..7e596b7 --- /dev/null +++ b/suixinkan_ios/Features/Projects/Models/ProjectModels.swift @@ -0,0 +1,440 @@ +// +// ProjectModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 项目详情实体,表示摄影师项目或店铺项目详情页需要展示的完整业务字段。 +struct PhotographerProjectDetailResponse: Decodable, Identifiable, Hashable { + let id: Int + let scenicId: Int + let cover: String + let coverProject: String + let coverCarousel: [String] + let name: String + let type: Int + let typeName: String + let status: Int + let statusName: String + let price: String + let otPrice: String + let priceDeposit: String + let description: String + let attrLabel: [String] + let projectContent: String + let sold: Int + let unitName: String + let materialNum: Int + let photoNum: Int + let videoNum: Int + let createdAt: String + let updatedAt: String + let creatorName: String + let creatorPhone: String + let creatorRoleName: String + let auditRemark: String + let photogList: [ProjectPhotographerBrief] + let scenicList: [ProjectScenicSpotBrief] + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case scenicId = "scenic_id" + case cover + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case name + case type + case typeName = "type_name" + case status + case statusName = "status_name" + case price + case otPrice = "ot_price" + case priceDeposit = "price_deposit" + case description + case attrLabel = "attr_label" + case projectContent = "project_content" + case sold + case unitName = "unit_name" + case materialNum = "material_num" + case photoNum = "photo_num" + case videoNum = "video_num" + case createdAt = "created_at" + case updatedAt = "updated_at" + case creatorName = "creator_name" + case creatorPhone = "creator_phone" + case creatorRoleName = "creator_role_name" + case auditRemark = "audit_remark" + case photogList = "photog_list" + case scenicList = "scenic_list" + } + + /// 宽松解码项目详情字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeProjectLossyInt(forKey: .id) ?? 0 + scenicId = try container.decodeProjectLossyInt(forKey: .scenicId) ?? 0 + cover = try container.decodeProjectLossyString(forKey: .cover) + coverProject = try container.decodeProjectLossyString(forKey: .coverProject) + coverCarousel = try container.decodeProjectLossyStringArray(forKey: .coverCarousel) + name = try container.decodeProjectLossyString(forKey: .name) + type = try container.decodeProjectLossyInt(forKey: .type) ?? 0 + typeName = try container.decodeProjectLossyString(forKey: .typeName) + status = try container.decodeProjectLossyInt(forKey: .status) ?? 0 + statusName = try container.decodeProjectLossyString(forKey: .statusName) + price = try container.decodeProjectLossyString(forKey: .price) + otPrice = try container.decodeProjectLossyString(forKey: .otPrice) + priceDeposit = try container.decodeProjectLossyString(forKey: .priceDeposit) + description = try container.decodeProjectLossyString(forKey: .description) + attrLabel = try container.decodeProjectLossyStringArray(forKey: .attrLabel) + projectContent = try container.decodeProjectLossyString(forKey: .projectContent) + sold = try container.decodeProjectLossyInt(forKey: .sold) ?? 0 + unitName = try container.decodeProjectLossyString(forKey: .unitName) + materialNum = try container.decodeProjectLossyInt(forKey: .materialNum) ?? 0 + photoNum = try container.decodeProjectLossyInt(forKey: .photoNum) ?? 0 + videoNum = try container.decodeProjectLossyInt(forKey: .videoNum) ?? 0 + createdAt = try container.decodeProjectLossyString(forKey: .createdAt) + updatedAt = try container.decodeProjectLossyString(forKey: .updatedAt) + creatorName = try container.decodeProjectLossyString(forKey: .creatorName) + creatorPhone = try container.decodeProjectLossyString(forKey: .creatorPhone) + creatorRoleName = try container.decodeProjectLossyString(forKey: .creatorRoleName) + auditRemark = try container.decodeProjectLossyString(forKey: .auditRemark) + photogList = (try? container.decode([ProjectPhotographerBrief].self, forKey: .photogList)) ?? [] + scenicList = (try? container.decode([ProjectScenicSpotBrief].self, forKey: .scenicList)) ?? [] + } +} + +/// 项目摄影师摘要实体,表示项目详情中的摄影师信息。 +struct ProjectPhotographerBrief: Decodable, Identifiable, Hashable { + let id: Int + let photogUid: Int + let nickname: String + let avatar: String + let orderNum: Int + let completedOrderCount: Int + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case photogUid = "photog_uid" + case nickname + case avatar + case orderNum = "order_num" + case completedOrderCount = "completed_order_count" + } + + /// 宽松解码摄影师摘要。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeProjectLossyInt(forKey: .id) ?? 0 + photogUid = try container.decodeProjectLossyInt(forKey: .photogUid) ?? 0 + nickname = try container.decodeProjectLossyString(forKey: .nickname) + avatar = try container.decodeProjectLossyString(forKey: .avatar) + orderNum = try container.decodeProjectLossyInt(forKey: .orderNum) ?? 0 + completedOrderCount = try container.decodeProjectLossyInt(forKey: .completedOrderCount) ?? 0 + } +} + +/// 项目打卡点摘要实体,表示项目关联的景点或打卡点。 +struct ProjectScenicSpotBrief: Decodable, Identifiable, Hashable { + let id: Int + let name: String + + /// 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + } + + /// 宽松解码打卡点摘要。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeProjectLossyInt(forKey: .id) ?? 0 + name = try container.decodeProjectLossyString(forKey: .name) + } +} + +/// 摄影师项目创建或编辑请求实体。 +struct ProjectCreateRequest: Encodable, Equatable { + let id: Int? + let type: Int + let scenicId: Int + let name: String + let price: String + let otPrice: String? + let coverProject: String + let coverCarousel: [String]? + let description: String + let attrLabel: [String]? + let extra: ProjectCreateExtra + let allowNFC: Int = 1 + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case type + case scenicId = "scenic_id" + case name + case price + case otPrice = "ot_price" + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case description + case attrLabel = "attr_label" + case extra + case allowNFC = "allow_nfc" + } +} + +/// 摄影师项目扩展配置实体,表示交付数量、押金和关联打卡点/摄影师。 +struct ProjectCreateExtra: Encodable, Equatable { + let priceDeposit: String + let materialNum: Int + let photoNum: Int + let videoNum: Int + let scenicSpotId: [Int] + let photogUid: [Int] + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case priceDeposit = "price_deposit" + case materialNum = "material_num" + case photoNum = "photo_num" + case videoNum = "video_num" + case scenicSpotId = "scenic_spot_id" + case photogUid = "photog_uid" + } +} + +/// 项目删除请求实体。 +struct ProjectDeleteRequest: Encodable, Equatable { + let id: Int +} + +/// 店铺项目可管理景区实体。 +struct StoreManagerScenicItem: Decodable, Identifiable, Hashable { + let id: Int + let name: String + + /// 字段映射。 + enum CodingKeys: String, CodingKey { + case id + case name + } + + /// 宽松解码店铺项目景区。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeProjectLossyInt(forKey: .id) ?? 0 + name = try container.decodeProjectLossyString(forKey: .name) + } +} + +/// 店铺项目套餐实体,表示多点位项目的套餐价格配置。 +struct StoreManagerPackageItem: Codable, Hashable { + let materialNum: Int + let price: String + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case materialNum = "material_num" + case price + } +} + +/// 店铺项目创建请求实体,用于多点位项目。 +struct StoreManagerCreateRequest: Encodable, Equatable { + let name: String + let storeId: Int? + let type: Int + let description: String + let coverProject: String? + let coverCarousel: [String]? + let projectRule: String? + let scenicId: [Int] + let settleSpotNum: Int + let price: Double + let priceMaterial: Double + let pricePhoto: Double + let priceVideo: Double + let priceMaterialAll: Double? + let packageList: [StoreManagerPackageItem]? + let userId: Int + let singleSpotMaterialNum: Int + let singleSpotPhotoNum: Int + let singleSpotVideoNum: Int + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case name + case storeId = "store_id" + case type + case description + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case projectRule = "project_rule" + case scenicId = "scenic_id" + case settleSpotNum = "settle_spot_num" + case price + case priceMaterial = "price_material" + case pricePhoto = "price_photo" + case priceVideo = "price_video" + case priceMaterialAll = "price_material_all" + case packageList = "package" + case userId = "user_id" + case singleSpotMaterialNum = "single_spot_material_num" + case singleSpotPhotoNum = "single_spot_photo_num" + case singleSpotVideoNum = "single_spot_video_num" + } +} + +/// 店铺项目更新请求实体,用于多点位项目。 +struct StoreManagerUpdateRequest: Encodable, Equatable { + let id: Int + let name: String + let type: Int + let description: String + let coverProject: String? + let coverCarousel: [String]? + let projectRule: String? + let scenicId: [Int] + let settleSpotNum: Int + let price: Double + let priceMaterial: Double + let pricePhoto: Double + let priceVideo: Double + let priceMaterialAll: Double? + let packageList: [StoreManagerPackageItem]? + let userId: Int + let singleSpotMaterialNum: Int + let singleSpotPhotoNum: Int + let singleSpotVideoNum: Int + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case type + case description + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case projectRule = "project_rule" + case scenicId = "scenic_id" + case settleSpotNum = "settle_spot_num" + case price + case priceMaterial = "price_material" + case pricePhoto = "price_photo" + case priceVideo = "price_video" + case priceMaterialAll = "price_material_all" + case packageList = "package" + case userId = "user_id" + case singleSpotMaterialNum = "single_spot_material_num" + case singleSpotPhotoNum = "single_spot_photo_num" + case singleSpotVideoNum = "single_spot_video_num" + } +} + +/// 店铺押金项目创建请求实体。 +struct StoreManagerOfflineCreateRequest: Encodable, Equatable { + let name: String + let scenicId: Int + let storeId: Int + let description: String + let price: Double + let coverProject: String + let coverCarousel: [String] + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case name + case scenicId = "scenic_id" + case storeId = "store_id" + case description + case price + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + } +} + +/// 店铺押金项目更新请求实体。 +struct StoreManagerOfflineUpdateRequest: Encodable, Equatable { + let id: Int + let name: String + let scenicId: Int + let description: String + let price: Double + let coverProject: String? + let coverCarousel: [String]? + let storeId: Int? + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case scenicId = "scenic_id" + case description + case price + case coverProject = "cover_project" + case coverCarousel = "cover_carousel" + case storeId = "store_id" + } +} + +/// 项目本地待上传图片实体,保存 PhotosPicker 读取后的图片数据。 +struct ProjectLocalImage: Identifiable, Equatable { + let id = UUID() + let data: Data + let fileName: String + let previewURL: String? +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeProjectLossyString(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 ? "1" : "0" + } + return "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeProjectLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(text) ?? Double(text).map(Int.init) + } + return nil + } + + /// 将数组或逗号分隔字符串宽松解码为字符串数组。 + func decodeProjectLossyStringArray(forKey key: Key) throws -> [String] { + if let values = try? decodeIfPresent([String].self, forKey: key) { + return values + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + .components(separatedBy: CharacterSet(charactersIn: ",,\n")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + return [] + } +} diff --git a/suixinkan_ios/Features/Projects/Projects.md b/suixinkan_ios/Features/Projects/Projects.md new file mode 100644 index 0000000..5914f5a --- /dev/null +++ b/suixinkan_ios/Features/Projects/Projects.md @@ -0,0 +1,31 @@ +# Projects 模块业务逻辑 + +## 模块职责 + +Projects 模块负责首页项目相关入口: +- `pm`、`project_edit`:摄影师项目管理。 +- `pm_manager`:店铺项目管理。 + +模块只保存项目列表、详情、编辑表单和上传中的临时状态,不写入 `AppSession`、`AccountContext`、TabBar 或首页全局状态。 + +## 摄影师项目 + +`ProjectManagementViewModel` 管理摄影师项目列表、搜索、分页、详情入口和删除动作。列表请求依赖当前景区 ID,缺少景区时清空本模块列表并停止请求。 + +`ProjectEditorViewModel` 管理新建和编辑表单。项目封面、轮播图先通过 `OSSUploadService.uploadProjectImage` 上传到 `project/yyyyMMdd/scenicId/...` 路径,再把最终 URL 提交给项目创建或编辑接口。 + +## 店铺项目 + +`StoreProjectManagementViewModel` 管理店铺项目列表和可管理景区。店铺项目按业务账号 ID 查询,缺少用户 ID 时不请求。 + +`StoreProjectEditorViewModel` 支持多点位项目和押金项目两种提交路径。多点位项目提交景区、点位、展示图和项目套餐;押金项目提交门店、押金金额、底片数量等字段。 + +## 接口边界 + +`ProjectAPI` 只封装项目模块需要的接口,包括摄影师项目列表/详情/创建/编辑/删除,以及店铺项目列表/详情/创建/更新/删除和可管理景区列表。 + +样片上传需要的轻量项目选择复用共享 `PhotographerProjectItem`,但不反向依赖 Projects 页面状态。 + +## 缓存边界 + +项目图片临时数据、OSS STS、编辑表单和分页状态都不落盘。网络图片展示继续交给 `RemoteImage` / Kingfisher。 diff --git a/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift b/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift new file mode 100644 index 0000000..453973a --- /dev/null +++ b/suixinkan_ios/Features/Projects/ViewControllers/ProjectViewControllers.swift @@ -0,0 +1,285 @@ +// +// ProjectViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 摄影师项目管理页。 +final class ProjectManagementViewController: ModuleTableViewController { + private let viewModel = ProjectManagementViewModel() + + override func viewDidLoad() { + title = "项目管理" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "新建", + style: .plain, + target: self, + action: #selector(createProject) + ) + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.items.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + cell.configure(title: item.name, subtitle: item.statusName, detail: "¥\(item.price)") + } + + override func didSelectTableRow(at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + navigationController?.pushViewController( + ProjectDetailViewController(projectId: item.id, storeMode: false), + animated: true + ) + } + + override func reloadContent() async { + await viewModel.reload(api: services.projectAPI, scenicId: services.currentScenicId) + } + + @objc private func createProject() { + navigationController?.pushViewController( + ProjectEditorViewController(projectId: nil), + animated: true + ) + } +} + +extension ProjectManagementViewModel: ViewModelBindable {} + +/// 店铺项目管理页。 +final class StoreProjectManagementViewController: ModuleTableViewController { + private let viewModel = StoreProjectManagementViewModel() + + override func viewDidLoad() { + title = "店铺项目" + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.filteredItems.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.filteredItems[indexPath.row] + cell.configure(title: item.name, subtitle: item.typeName, detail: "¥\(item.price)") + } + + override func didSelectTableRow(at indexPath: IndexPath) { + let item = viewModel.filteredItems[indexPath.row] + navigationController?.pushViewController( + ProjectDetailViewController(projectId: item.id, storeMode: true), + animated: true + ) + } + + override func reloadContent() async { + await viewModel.reload(api: services.projectAPI, userId: services.userId) + } +} + +extension StoreProjectManagementViewModel: ViewModelBindable {} + +/// 项目详情页。 +final class ProjectDetailViewController: ModuleTableViewController { + private let projectId: Int + private let storeMode: Bool + private let photographerViewModel = ProjectManagementViewModel() + private let storeViewModel = StoreProjectManagementViewModel() + + init(projectId: Int, storeMode: Bool) { + self.projectId = projectId + self.storeMode = storeMode + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + title = "项目详情" + super.viewDidLoad() + if storeMode { + storeViewModel.onChange = { [weak self] in self?.reloadTable() } + } else { + photographerViewModel.onChange = { [weak self] in self?.reloadTable() } + } + } + + private var detail: PhotographerProjectDetailResponse? { + storeMode ? storeViewModel.selectedDetail : photographerViewModel.selectedDetail + } + + override func tableRowCount() -> Int { + detail == nil ? 0 : 4 + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + guard let detail else { return } + switch indexPath.row { + case 0: cell.configure(title: "项目名称", subtitle: detail.name) + case 1: cell.configure(title: "简介", subtitle: detail.description) + case 2: cell.configure(title: "价格", subtitle: detail.price) + default: cell.configure(title: "打卡点", subtitle: "\(detail.scenicList.count) 个") + } + } + + override func reloadContent() async { + if storeMode { + await storeViewModel.loadDetail(id: projectId, api: services.projectAPI) + } else { + await photographerViewModel.loadDetail(id: projectId, api: services.projectAPI) + } + } +} + +/// 摄影师项目编辑页。 +final class ProjectEditorViewController: ModuleTableViewController { + private let viewModel = ProjectEditorViewModel(mode: .create) + private let nameField = UITextField() + private let descriptionField = UITextField() + private let priceField = UITextField() + private let projectId: Int? + + init(projectId: Int?) { + self.projectId = projectId + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + title = projectId == nil ? "新建项目" : "编辑项目" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "保存", + style: .done, + target: self, + action: #selector(save) + ) + super.viewDidLoad() + setupHeader() + wireViewModel(viewModel) { [weak self] in self?.fillFormIfNeeded() } + } + + private func setupHeader() { + nameField.placeholder = "项目名称" + nameField.borderStyle = .roundedRect + descriptionField.placeholder = "项目简介" + descriptionField.borderStyle = .roundedRect + priceField.placeholder = "价格" + priceField.borderStyle = .roundedRect + priceField.keyboardType = .decimalPad + let stack = UIStackView(arrangedSubviews: [nameField, descriptionField, priceField]) + stack.axis = .vertical + stack.spacing = 8 + stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 132) + stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) + stack.isLayoutMarginsRelativeArrangement = true + tableView.tableHeaderView = stack + } + + override func tableRowCount() -> Int { 0 } + + override func reloadContent() async { + guard let projectId else { return } + if let detail = try? await services.projectAPI.projectDetail(id: projectId) { + viewModel.apply(detail) + fillFormIfNeeded() + } + } + + private func fillFormIfNeeded() { + if nameField.text?.isEmpty != false { nameField.text = viewModel.name } + if descriptionField.text?.isEmpty != false { descriptionField.text = viewModel.descriptionText } + if priceField.text?.isEmpty != false { priceField.text = viewModel.price } + } + + @objc private func save() { + viewModel.name = nameField.text ?? "" + viewModel.descriptionText = descriptionField.text ?? "" + viewModel.price = priceField.text ?? "" + Task { + let userId = Int(services.userId ?? "") + let success = await viewModel.submit( + scenicId: services.currentScenicId, + userId: userId, + api: services.projectAPI, + uploadService: services.ossUploadService + ) + if success { + navigationController?.popViewController(animated: true) + } else if let message = viewModel.errorMessage { + services.toastCenter.show(message) + } + } + } +} + +extension ProjectEditorViewModel: ViewModelBindable {} + +/// 店铺项目编辑页。 +final class StoreProjectEditorViewController: ModuleTableViewController { + private let viewModel = StoreProjectEditorViewModel(mode: .create) + private let nameField = UITextField() + + init(projectId: Int?) { + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + title = "店铺项目" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "保存", + style: .done, + target: self, + action: #selector(save) + ) + super.viewDidLoad() + nameField.placeholder = "项目名称" + nameField.borderStyle = .roundedRect + nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) + tableView.tableHeaderView = nameField + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.scenicList.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let scenic = viewModel.scenicList[indexPath.row] + cell.configure(title: scenic.name, subtitle: viewModel.selectedScenicIds.contains(scenic.id) ? "已选择" : nil) + } + + override func reloadContent() async { + guard let userId = Int(services.userId ?? "") else { return } + await viewModel.loadScenicList(api: services.projectAPI, userId: userId) + } + + @objc private func save() { + viewModel.name = nameField.text ?? "" + Task { + guard let userId = Int(services.userId ?? "") else { return } + let success = await viewModel.submit( + userId: userId, + api: services.projectAPI, + uploadService: services.ossUploadService + ) + if success { navigationController?.popViewController(animated: true) } + } + } +} + +extension StoreProjectEditorViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift b/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift new file mode 100644 index 0000000..d2cabf0 --- /dev/null +++ b/suixinkan_ios/Features/Projects/ViewModels/ProjectViewModels.swift @@ -0,0 +1,624 @@ +// +// ProjectViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 项目表单模式,区分新建和编辑。 +enum ProjectEditorMode: Equatable { + case create + case edit(PhotographerProjectDetailResponse) +} + +/// 店铺项目表单模式,区分新建和编辑。 +enum StoreProjectEditorMode: Equatable { + case create + case edit(PhotographerProjectDetailResponse) +} + +/// 项目表单错误实体,表示校验、上传或提交失败。 +enum ProjectEditorError: LocalizedError, Equatable { + case missingScenic + case missingUser + case missingName + case missingDescription + case missingPrice + case missingSpot + case missingImage + case missingStore + + /// 错误文案。 + var errorDescription: String? { + switch self { + case .missingScenic: return "当前缺少景区信息" + case .missingUser: return "当前缺少用户信息" + case .missingName: return "请输入项目名称" + case .missingDescription: return "请输入项目简介" + case .missingPrice: return "请填写有效价格" + case .missingSpot: return "请至少选择一个打卡点" + case .missingImage: return "请至少上传项目封面" + case .missingStore: return "请选择所属门店" + } + } +} + +/// 摄影师项目管理 ViewModel,负责项目列表、分页、详情和删除。 +@MainActor +final class ProjectManagementViewModel { + var onChange: (() -> Void)? + private(set) var items: [PhotographerProjectItem] = [] { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var loadingMore = false { didSet { onChange?() } } + private(set) var hasMore = false { didSet { onChange?() } } + private(set) var selectedDetail: PhotographerProjectDetailResponse? { didSet { onChange?() } } + var searchText = "" { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private var page = 1 + private var total = 0 + private let pageSize = 10 + + /// 重新加载摄影师项目列表。 + func reload(api: any ProjectServing, scenicId: Int?) async { + guard let scenicId else { + resetList() + return + } + loading = true + errorMessage = nil + defer { loading = false } + do { + page = 1 + let response = try await api.projectList( + scenicId: scenicId, + name: normalizedSearch, + page: page, + pageSize: pageSize + ) + total = response.total + items = sorted(response.list) + hasMore = items.count < total + page = 2 + } catch { + resetList() + errorMessage = error.localizedDescription + } + } + + /// 加载下一页摄影师项目。 + func loadMore(api: any ProjectServing, scenicId: Int?) async { + guard let scenicId, hasMore, !loadingMore else { return } + loadingMore = true + defer { loadingMore = false } + do { + let response = try await api.projectList(scenicId: scenicId, name: normalizedSearch, page: page, pageSize: pageSize) + total = response.total + items = sorted(deduplicated(items + response.list)) + hasMore = items.count < total + page += 1 + } catch { + errorMessage = error.localizedDescription + } + } + + /// 加载项目详情。 + func loadDetail(id: Int, api: any ProjectServing) async { + do { + selectedDetail = try await api.projectDetail(id: id) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 删除摄影师项目并从本地列表移除。 + func delete(id: Int, api: any ProjectServing) async { + do { + try await api.deleteProject(id: id) + items.removeAll { $0.id == id } + total = max(total - 1, 0) + hasMore = items.count < total + } catch { + errorMessage = error.localizedDescription + } + } + + private var normalizedSearch: String? { + let value = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private func resetList() { + items = [] + loading = false + loadingMore = false + hasMore = false + page = 1 + total = 0 + } + + private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { + values.sorted { lhs, rhs in + if lhs.status != rhs.status { return lhs.status < rhs.status } + return lhs.id > rhs.id + } + } + + private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] { + var result: [Int: PhotographerProjectItem] = [:] + values.forEach { result[$0.id] = $0 } + return Array(result.values) + } +} + +/// 项目编辑 ViewModel,负责摄影师项目表单、图片上传和提交。 +@MainActor +final class ProjectEditorViewModel { + var onChange: (() -> Void)? + var name = "" { didSet { onChange?() } } + var descriptionText = "" { didSet { onChange?() } } + var price = "" { didSet { onChange?() } } + var otPrice = "" { didSet { onChange?() } } + var deposit = "" { didSet { onChange?() } } + var attrLabelText = "" { didSet { onChange?() } } + var materialNum = "1" { didSet { onChange?() } } + var photoNum = "1" { didSet { onChange?() } } + var videoNum = "1" { didSet { onChange?() } } + var selectedSpotIds: Set = [] { didSet { onChange?() } } + var coverImage: ProjectLocalImage? { didSet { onChange?() } } + var carouselImages: [ProjectLocalImage] = [] { didSet { onChange?() } } + var existingCoverURL = "" { didSet { onChange?() } } + var existingCarouselURLs: [String] = [] { didSet { onChange?() } } + private(set) var submitting = false { didSet { onChange?() } } + private(set) var uploadProgress = 0 { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private var mode: ProjectEditorMode + + /// 初始化摄影师项目表单,并在编辑模式下回填详情。 + init(mode: ProjectEditorMode) { + self.mode = mode + if case let .edit(detail) = mode { + apply(detail) + } + } + + /// 回填编辑详情,保留同一个 ObservableObject 实例。 + func apply(_ detail: PhotographerProjectDetailResponse) { + mode = .edit(detail) + name = detail.name + descriptionText = detail.description + price = detail.price + otPrice = detail.otPrice + deposit = detail.priceDeposit + attrLabelText = detail.attrLabel.joined(separator: ",") + materialNum = "\(max(detail.materialNum, 0))" + photoNum = "\(max(detail.photoNum, 0))" + videoNum = "\(max(detail.videoNum, 0))" + selectedSpotIds = Set(detail.scenicList.map(\.id)) + existingCoverURL = detail.coverProject + existingCarouselURLs = detail.coverCarousel + coverImage = nil + carouselImages = [] + } + + /// 提交摄影师项目,必要时先上传封面和轮播图。 + func submit( + scenicId: Int?, + userId: Int?, + api: any ProjectServing, + uploadService: any OSSUploadServing + ) async -> Bool { + guard !submitting else { return false } + guard let scenicId else { return fail(.missingScenic) } + guard let userId, userId > 0 else { return fail(.missingUser) } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { return fail(.missingName) } + guard !normalizedDescription.isEmpty else { return fail(.missingDescription) } + guard Double(price) ?? 0 > 0 else { return fail(.missingPrice) } + guard !selectedSpotIds.isEmpty else { return fail(.missingSpot) } + + submitting = true + errorMessage = nil + uploadProgress = 0 + defer { submitting = false } + + do { + let coverURL = try await resolveCoverURL(scenicId: scenicId, uploadService: uploadService) + guard !coverURL.isEmpty else { throw ProjectEditorError.missingImage } + let carouselURLs = try await resolveCarouselURLs(scenicId: scenicId, uploadService: uploadService) + let request = ProjectCreateRequest( + id: editID, + type: 11, + scenicId: scenicId, + name: normalizedName, + price: normalizedMoney(price) ?? "0.00", + otPrice: normalizedMoney(otPrice), + coverProject: coverURL, + coverCarousel: carouselURLs.isEmpty ? nil : carouselURLs, + description: normalizedDescription, + attrLabel: normalizedLabels, + extra: ProjectCreateExtra( + priceDeposit: normalizedMoney(deposit) ?? "0.00", + materialNum: max(Int(materialNum) ?? 0, 0), + photoNum: max(Int(photoNum) ?? 0, 0), + videoNum: max(Int(videoNum) ?? 0, 0), + scenicSpotId: selectedSpotIds.sorted(), + photogUid: [userId] + ) + ) + if editID == nil { + try await api.createProject(request) + } else { + try await api.editProject(request) + } + uploadProgress = 100 + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + private var editID: Int? { + if case let .edit(detail) = mode { + return detail.id + } + return nil + } + + private var normalizedLabels: [String]? { + let labels = attrLabelText + .components(separatedBy: CharacterSet(charactersIn: ",,")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return labels.isEmpty ? nil : labels + } + + private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { + if let coverImage { + return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in + self?.uploadProgress = progress + } + } + return existingCoverURL + } + + private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { + var urls = existingCarouselURLs + for image in carouselImages { + let url = try await uploadService.uploadProjectImage(data: image.data, fileName: image.fileName, scenicId: scenicId) { [weak self] progress in + self?.uploadProgress = progress + } + urls.append(url) + } + return urls + } + + private func normalizedMoney(_ value: String) -> String? { + let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + return amount > 0 ? String(format: "%.2f", amount) : nil + } + + private func fail(_ error: ProjectEditorError) -> Bool { + errorMessage = error.localizedDescription + return false + } +} + +/// 店铺项目管理 ViewModel,负责店铺项目列表、筛选、详情和删除。 +@MainActor +final class StoreProjectManagementViewModel { + var onChange: (() -> Void)? + private(set) var items: [PhotographerProjectItem] = [] { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var selectedDetail: PhotographerProjectDetailResponse? { didSet { onChange?() } } + var searchText = "" { didSet { onChange?() } } + var selectedType = 0 { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 根据本地筛选条件返回展示项目。 + var filteredItems: [PhotographerProjectItem] { + let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return items.filter { item in + let typeMatched = selectedType == 0 || item.type == selectedType + let keywordMatched = keyword.isEmpty || item.name.localizedCaseInsensitiveContains(keyword) + return typeMatched && keywordMatched + } + } + + /// 重新加载店铺项目列表。 + func reload(api: any ProjectServing, userId: String?) async { + let normalizedUserId = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedUserId.isEmpty else { + items = [] + return + } + loading = true + errorMessage = nil + defer { loading = false } + do { + let response = try await api.storeManagerProjectList(userId: normalizedUserId, page: 1, pageSize: 100) + items = response.list + } catch { + items = [] + errorMessage = error.localizedDescription + } + } + + /// 加载店铺项目详情。 + func loadDetail(id: Int, api: any ProjectServing) async { + do { + selectedDetail = try await api.storeManagerProjectDetail(id: id) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 删除店铺项目。 + func delete(id: Int, api: any ProjectServing) async { + do { + try await api.storeManagerDeleteProject(id: id) + items.removeAll { $0.id == id } + } catch { + errorMessage = error.localizedDescription + } + } +} + +/// 店铺项目编辑 ViewModel,负责多点位和押金项目表单提交。 +@MainActor +final class StoreProjectEditorViewModel { + var onChange: (() -> Void)? + var projectType = 19 { didSet { onChange?() } } + var scenicList: [StoreManagerScenicItem] = [] { didSet { onChange?() } } + var storeList: [StoreItem] = [] { didSet { onChange?() } } + var selectedScenicIds: Set = [] { didSet { onChange?() } } + var selectedStoreId: Int? { didSet { onChange?() } } + var name = "" { didSet { onChange?() } } + var descriptionText = "" { didSet { onChange?() } } + var coverImage: ProjectLocalImage? { didSet { onChange?() } } + var carouselImages: [ProjectLocalImage] = [] { didSet { onChange?() } } + var existingCoverURL = "" { didSet { onChange?() } } + var existingCarouselURLs: [String] = [] { didSet { onChange?() } } + var settleSpotNum = "1" { didSet { onChange?() } } + var projectPrice = "" { didSet { onChange?() } } + var priceMaterial = "" { didSet { onChange?() } } + var pricePhoto = "" { didSet { onChange?() } } + var priceVideo = "" { didSet { onChange?() } } + var priceDeposit = "" { didSet { onChange?() } } + var materialNum = "1" { didSet { onChange?() } } + var photoNum = "1" { didSet { onChange?() } } + var videoNum = "1" { didSet { onChange?() } } + private(set) var submitting = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private var mode: StoreProjectEditorMode + + /// 初始化店铺项目表单,并在编辑模式下回填详情。 + init(mode: StoreProjectEditorMode) { + self.mode = mode + if case let .edit(detail) = mode { + apply(detail) + } + } + + /// 回填编辑详情,保留同一个 ObservableObject 实例。 + func apply(_ detail: PhotographerProjectDetailResponse) { + mode = .edit(detail) + projectType = detail.type == 4 ? 4 : 19 + name = detail.name + descriptionText = detail.description + existingCoverURL = detail.coverProject + existingCarouselURLs = detail.coverCarousel + selectedScenicIds = Set(detail.scenicList.map(\.id)) + projectPrice = detail.price + priceDeposit = detail.priceDeposit + materialNum = "\(max(detail.materialNum, 0))" + photoNum = "\(max(detail.photoNum, 0))" + videoNum = "\(max(detail.videoNum, 0))" + coverImage = nil + carouselImages = [] + } + + /// 加载店铺项目可管理景区。 + func loadScenicList(api: any ProjectServing, userId: Int) async { + guard scenicList.isEmpty, userId > 0 else { return } + do { + scenicList = try await api.storeManagerScenicList(userId: "\(userId)").list + } catch { + errorMessage = error.localizedDescription + } + } + + /// 加载当前景区下门店列表。 + func loadStoreList(api: any ProjectServing) async { + guard projectType == 4, let scenicId = selectedScenicIds.first else { return } + do { + storeList = try await api.storeAll().list.filter { $0.scenicId == scenicId } + if selectedStoreId == nil { + selectedStoreId = storeList.first?.id + } + } catch { + errorMessage = error.localizedDescription + } + } + + /// 切换店铺项目景区选择。 + func toggleScenic(_ id: Int) { + if projectType == 4 { + selectedScenicIds = [id] + } else if selectedScenicIds.contains(id) { + selectedScenicIds.remove(id) + } else { + selectedScenicIds.insert(id) + } + } + + /// 提交店铺项目。 + func submit(userId: Int, api: any ProjectServing, uploadService: any OSSUploadServing) async -> Bool { + guard !submitting else { return false } + guard userId > 0 else { return fail(.missingUser) } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { return fail(.missingName) } + guard !normalizedDescription.isEmpty else { return fail(.missingDescription) } + guard !selectedScenicIds.isEmpty else { return fail(.missingScenic) } + guard Double(projectType == 4 ? priceDeposit : projectPrice) ?? 0 > 0 else { return fail(.missingPrice) } + if projectType == 4, selectedStoreId == nil { return fail(.missingStore) } + + submitting = true + errorMessage = nil + defer { submitting = false } + + do { + let scenicIdForUpload = selectedScenicIds.first ?? 0 + let coverURL = try await resolveCoverURL(scenicId: scenicIdForUpload, uploadService: uploadService) + guard !coverURL.isEmpty else { throw ProjectEditorError.missingImage } + let carouselURLs = try await resolveCarouselURLs(scenicId: scenicIdForUpload, uploadService: uploadService) + if projectType == 4 { + try await submitOfflineProject( + api: api, + name: normalizedName, + description: normalizedDescription, + coverURL: coverURL, + carouselURLs: carouselURLs + ) + } else { + try await submitMultiPointProject( + userId: userId, + api: api, + name: normalizedName, + description: normalizedDescription, + coverURL: coverURL, + carouselURLs: carouselURLs + ) + } + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + private var editID: Int? { + if case let .edit(detail) = mode { + return detail.id + } + return nil + } + + private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String { + if let coverImage { + return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in } + } + return existingCoverURL + } + + private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { + var urls = existingCarouselURLs + for image in carouselImages { + let url = try await uploadService.uploadProjectImage(data: image.data, fileName: image.fileName, scenicId: scenicId) { _ in } + urls.append(url) + } + return urls + } + + private func submitMultiPointProject( + userId: Int, + api: any ProjectServing, + name: String, + description: String, + coverURL: String, + carouselURLs: [String] + ) async throws { + if let editID { + try await api.storeManagerUpdate( + StoreManagerUpdateRequest( + id: editID, + name: name, + type: 19, + description: description, + coverProject: coverURL, + coverCarousel: carouselURLs, + projectRule: nil, + scenicId: selectedScenicIds.sorted(), + settleSpotNum: max(Int(settleSpotNum) ?? 1, 1), + price: Double(projectPrice) ?? 0, + priceMaterial: Double(priceMaterial) ?? 0, + pricePhoto: Double(pricePhoto) ?? 0, + priceVideo: Double(priceVideo) ?? 0, + priceMaterialAll: nil, + packageList: nil, + userId: userId, + singleSpotMaterialNum: max(Int(materialNum) ?? 0, 0), + singleSpotPhotoNum: max(Int(photoNum) ?? 0, 0), + singleSpotVideoNum: max(Int(videoNum) ?? 0, 0) + ) + ) + } else { + try await api.storeManagerCreate( + StoreManagerCreateRequest( + name: name, + storeId: nil, + type: 19, + description: description, + coverProject: coverURL, + coverCarousel: carouselURLs, + projectRule: nil, + scenicId: selectedScenicIds.sorted(), + settleSpotNum: max(Int(settleSpotNum) ?? 1, 1), + price: Double(projectPrice) ?? 0, + priceMaterial: Double(priceMaterial) ?? 0, + pricePhoto: Double(pricePhoto) ?? 0, + priceVideo: Double(priceVideo) ?? 0, + priceMaterialAll: nil, + packageList: nil, + userId: userId, + singleSpotMaterialNum: max(Int(materialNum) ?? 0, 0), + singleSpotPhotoNum: max(Int(photoNum) ?? 0, 0), + singleSpotVideoNum: max(Int(videoNum) ?? 0, 0) + ) + ) + } + } + + private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws { + let scenicId = selectedScenicIds.first ?? 0 + let storeId = selectedStoreId ?? 0 + if let editID { + try await api.storeManagerOfflineUpdate( + StoreManagerOfflineUpdateRequest( + id: editID, + name: name, + scenicId: scenicId, + description: description, + price: Double(priceDeposit) ?? 0, + coverProject: coverURL, + coverCarousel: carouselURLs, + storeId: storeId + ) + ) + } else { + try await api.storeManagerOfflineCreate( + StoreManagerOfflineCreateRequest( + name: name, + scenicId: scenicId, + storeId: storeId, + description: description, + price: Double(priceDeposit) ?? 0, + coverProject: coverURL, + coverCarousel: carouselURLs + ) + ) + } + } + + private func fail(_ error: ProjectEditorError) -> Bool { + errorMessage = error.localizedDescription + return false + } +} diff --git a/suixinkan_ios/Features/PunchPoint/API/PunchPointAPI.swift b/suixinkan_ios/Features/PunchPoint/API/PunchPointAPI.swift new file mode 100644 index 0000000..85e4adb --- /dev/null +++ b/suixinkan_ios/Features/PunchPoint/API/PunchPointAPI.swift @@ -0,0 +1,86 @@ +// +// PunchPointAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 打卡点服务协议,抽象列表、详情和增删改接口以便单元测试替换。 +@MainActor +protocol PunchPointServing { + /// 获取指定景区的打卡点列表。 + func punchPointList(scenicId: Int, status: Int, page: Int, pageSize: Int) async throws -> ListPayload + + /// 获取指定打卡点详情。 + func punchPointInfo(id: Int) async throws -> PunchPointItem + + /// 新增打卡点。 + func addPunchPoint(_ request: AddPunchPointRequest) async throws + + /// 编辑打卡点。 + func editPunchPoint(_ request: EditPunchPointRequest) async throws + + /// 删除打卡点。 + func deletePunchPoint(id: Int) async throws +} + +/// 打卡点 API,负责封装旧工程打卡点管理相关接口。 +@MainActor +final class PunchPointAPI: PunchPointServing { + private let client: APIClient + + /// 初始化打卡点 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取指定景区的打卡点列表。 + func punchPointList(scenicId: Int, status: Int = 0, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic-spot/list", + queryItems: [ + URLQueryItem(name: "scenic_area_id", value: "\(scenicId)"), + URLQueryItem(name: "status", value: "\(max(status, 0))"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } + + /// 获取指定打卡点详情。 + func punchPointInfo(id: Int) async throws -> PunchPointItem { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic-spot/info", + queryItems: [URLQueryItem(name: "id", value: "\(id)")] + ) + ) + } + + /// 新增打卡点。 + func addPunchPoint(_ request: AddPunchPointRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/add", body: request) + ) as EmptyPayload + } + + /// 编辑打卡点。 + func editPunchPoint(_ request: EditPunchPointRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/edit", body: request) + ) as EmptyPayload + } + + /// 删除打卡点。 + func deletePunchPoint(id: Int) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/delete", body: PunchPointDeleteRequest(id: id)) + ) as EmptyPayload + } +} diff --git a/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift b/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift new file mode 100644 index 0000000..8ad66e6 --- /dev/null +++ b/suixinkan_ios/Features/PunchPoint/Models/PunchPointModels.swift @@ -0,0 +1,252 @@ +// +// PunchPointModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 打卡点筛选实体,表示列表页可选择的审核或运营状态。 +enum PunchPointFilter: Int, CaseIterable, Identifiable { + case all = 0 + case operating = 1 + case paused = 2 + case pendingReview = 3 + case rejected = 4 + + var id: Int { rawValue } + + /// 筛选项展示标题。 + var title: String { + switch self { + case .all: "全部" + case .operating: "运营中" + case .paused: "已暂停" + case .pendingReview: "待审核" + case .rejected: "已驳回" + } + } +} + +/// 打卡点区域实体,表示点位经纬度和地址。 +struct PunchPointRegion: Codable, Hashable { + var lat: Double + var lot: Double + var address: String + var scenicSpotStr: String? + + enum CodingKeys: String, CodingKey { + case lat + case lot + case address + case scenicSpotStr = "scenic_spot_str" + } + + /// 创建打卡点区域实体。 + init(lat: Double, lot: Double, address: String, scenicSpotStr: String? = nil) { + self.lat = lat + self.lot = lot + self.address = address + self.scenicSpotStr = scenicSpotStr + } + + /// 宽松解码区域字段,兼容经纬度数字和字符串混合返回。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + lat = try container.decodeLossyDouble(forKey: .lat) ?? 0 + lot = try container.decodeLossyDouble(forKey: .lot) ?? 0 + address = try container.decodeLossyString(forKey: .address) + scenicSpotStr = try container.decodeIfPresent(String.self, forKey: .scenicSpotStr) + } +} + +/// 打卡点列表项实体,表示一个景区下可管理的打卡点。 +struct PunchPointItem: Decodable, Hashable, Identifiable { + let id: Int + let scenicAreaId: Int + let name: String + let status: Int + let statusLabel: String + let description: String + let region: PunchPointRegion? + let scenicSpotStr: String + let guideImages: [String] + let mpQrcode: String + let createdAt: String + let creator: String + let creatorPhone: String + let auditor: String + let auditTime: String + let auditRemark: String + + enum CodingKeys: String, CodingKey { + case id + case scenicAreaId = "scenic_area_id" + case name + case status + case statusLabel = "status_label" + case description + case region + case scenicSpotStr = "scenic_spot_str" + case guideImages = "guide_imgs" + case mpQrcode = "mp_qrcode" + case createdAt = "created_at" + case creator + case creatorPhone = "creator_phone" + case auditor + case auditTime = "audit_time" + case auditRemark = "audit_remark" + } + + /// 创建打卡点列表项,主要用于测试和表单兜底展示。 + init( + id: Int, + scenicAreaId: Int = 0, + name: String, + status: Int = 0, + statusLabel: String = "", + description: String = "", + region: PunchPointRegion? = nil, + scenicSpotStr: String = "", + guideImages: [String] = [], + mpQrcode: String = "", + createdAt: String = "", + creator: String = "", + creatorPhone: String = "", + auditor: String = "", + auditTime: String = "", + auditRemark: String = "" + ) { + self.id = id + self.scenicAreaId = scenicAreaId + self.name = name + self.status = status + self.statusLabel = statusLabel + self.description = description + self.region = region + self.scenicSpotStr = scenicSpotStr + self.guideImages = guideImages + self.mpQrcode = mpQrcode + self.createdAt = createdAt + self.creator = creator + self.creatorPhone = creatorPhone + self.auditor = auditor + self.auditTime = auditTime + self.auditRemark = auditRemark + } + + /// 宽松解码打卡点字段,兼容后端字符串和数字混合返回。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + scenicAreaId = try container.decodeLossyInt(forKey: .scenicAreaId) ?? 0 + name = try container.decodeLossyString(forKey: .name) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusLabel = try container.decodeLossyString(forKey: .statusLabel) + description = try container.decodeLossyString(forKey: .description) + region = try container.decodeIfPresent(PunchPointRegion.self, forKey: .region) + scenicSpotStr = try container.decodeLossyString(forKey: .scenicSpotStr) + guideImages = (try? container.decodeIfPresent([String].self, forKey: .guideImages)) ?? [] + mpQrcode = try container.decodeLossyString(forKey: .mpQrcode) + createdAt = try container.decodeLossyString(forKey: .createdAt) + creator = try container.decodeLossyString(forKey: .creator) + creatorPhone = try container.decodeLossyString(forKey: .creatorPhone) + auditor = try container.decodeLossyString(forKey: .auditor) + auditTime = try container.decodeLossyString(forKey: .auditTime) + auditRemark = try container.decodeLossyString(forKey: .auditRemark) + } +} + +/// 新增打卡点请求实体,表示提交给后端的点位表单。 +struct AddPunchPointRequest: Encodable, Equatable { + let scenicAreaId: String + let name: String + let description: String + let region: PunchPointRegion + let scenicSpotStr: String + let guideImages: [String] + + enum CodingKeys: String, CodingKey { + case scenicAreaId = "scenic_area_id" + case name + case description + case region + case scenicSpotStr = "scenic_spot_str" + case guideImages = "guide_imgs" + } +} + +/// 编辑打卡点请求实体,表示已有点位的完整修改内容。 +struct EditPunchPointRequest: Encodable, Equatable { + let id: Int + let scenicAreaId: String + let name: String + let description: String + let region: PunchPointRegion + let scenicSpotStr: String + let guideImages: [String] + + enum CodingKeys: String, CodingKey { + case id + case scenicAreaId = "scenic_area_id" + case name + case description + case region + case scenicSpotStr = "scenic_spot_str" + case guideImages = "guide_imgs" + } +} + +/// 删除打卡点请求实体,表示要删除的点位 ID。 +struct PunchPointDeleteRequest: Encodable, Equatable { + let id: Int +} + +/// 打卡点本地待上传图片实体,保存 PhotosPicker 读取后的内存图片。 +struct PunchPointLocalImage: Identifiable, Equatable { + let id = UUID() + let data: Data + let fileName: String + var remoteURL: String? + var progress: Int + + /// 创建待上传打卡点图片。 + init(data: Data, fileName: String, remoteURL: String? = nil, progress: Int = 0) { + self.data = data + self.fileName = fileName + self.remoteURL = remoteURL + self.progress = progress + } +} + +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 ? "1" : "0" } + return "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// 将 String、Double 和 Int 宽松解码为 Double。 + func decodeLossyDouble(forKey key: Key) throws -> Double? { + if let value = try? decodeIfPresent(Double.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Int.self, forKey: key) { return Double(value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} diff --git a/suixinkan_ios/Features/PunchPoint/PunchPoint.md b/suixinkan_ios/Features/PunchPoint/PunchPoint.md new file mode 100644 index 0000000..14fb6e9 --- /dev/null +++ b/suixinkan_ios/Features/PunchPoint/PunchPoint.md @@ -0,0 +1,37 @@ +# PunchPoint 模块业务逻辑 + +## 模块职责 + +PunchPoint 模块负责首页 `checkin_points` 打卡点管理入口。 + +本模块包含打卡点列表、状态筛选、分页、详情、二维码、新建、编辑、删除、前台定位选点和图片 OSS 上传。打卡点列表和表单状态只保存在模块 ViewModel 内,不进入 `AppSession`、`AccountContext`、TabBar 或首页状态。 + +## 数据来源 + +- 当前景区 ID 从 `AccountContext.currentScenic` 读取。 +- 列表接口使用 `/api/yf-handset-app/photog/scenic-spot/list`。 +- 详情接口使用 `/api/yf-handset-app/photog/scenic-spot/info`。 +- 新建、编辑、删除分别使用 `/add`、`/edit`、`/delete`。 +- 图片上传统一使用 `OSSUploadService.uploadPunchPointImage`。 + +## 页面流程 + +`PunchPointListViewModel` 管理状态筛选、分页、详情兜底和删除刷新。缺少当前景区时清空列表并停止请求。 + +`PunchPointEditorViewModel` 管理新增和编辑表单。提交前校验名称、坐标、地址和图片;本地图片会先上传 OSS,全部拿到最终 URL 后再提交打卡点接口。上传失败时不提交业务接口。 + +编辑或删除成功后,页面会触发 `ScenicSpotContext.reload`,保证素材、样片等依赖打卡点选择器的页面能看到最新数据。 + +## 定位边界 + +当前实现使用 `ForegroundLocationProvider` 做前台即时定位和地址反解析,并允许手动填写经纬度。本轮不做后台定位、不缓存定位结果,也不把定位权限状态落盘。 + +后续如果接入完整高德地图选点 UI,只需要替换编辑页的选点组件,继续把经纬度、地址回填到 `PunchPointEditorViewModel`。 + +## 缓存边界 + +打卡点列表、详情、表单、本地图片、上传进度和 OSS STS 都不落盘。远程图片缓存继续交给 Kingfisher 的 `RemoteImage`。 + +## 测试要求 + +新增打卡点逻辑时,需要同步补充 API、ViewModel 和路由测试。测试失败时先修复问题,再继续迁移后续功能。 diff --git a/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift b/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift new file mode 100644 index 0000000..bdff60d --- /dev/null +++ b/suixinkan_ios/Features/PunchPoint/ViewControllers/PunchPointViewControllers.swift @@ -0,0 +1,217 @@ +// +// PunchPointViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import SnapKit +import UIKit + +private extension PunchPointItem { + var displayAddress: String { + region?.address ?? scenicSpotStr + } +} + +/// 打卡点列表页。 +final class PunchPointListViewController: ModuleTableViewController { + private let viewModel = PunchPointListViewModel() + + override func viewDidLoad() { + title = "打卡点" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "新建", + style: .plain, + target: self, + action: #selector(createPunchPoint) + ) + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.items.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + cell.configure(title: item.name, subtitle: item.displayAddress, detail: item.statusLabel) + } + + override func didSelectTableRow(at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + navigationController?.pushViewController( + PunchPointDetailViewController(punchPointId: item.id, summary: item), + animated: true + ) + } + + override func reloadContent() async { + await viewModel.reload(scenicId: services.currentScenicId, api: services.punchPointAPI) + } + + @objc private func createPunchPoint() { + navigationController?.pushViewController(PunchPointEditorViewController(punchPointId: nil), animated: true) + } +} + +extension PunchPointListViewModel: ViewModelBindable {} + +/// 打卡点详情页。 +final class PunchPointDetailViewController: ModuleTableViewController { + private let punchPointId: Int + private let summary: PunchPointItem? + private let viewModel = PunchPointListViewModel() + + init(punchPointId: Int, summary: PunchPointItem?) { + self.punchPointId = punchPointId + self.summary = summary + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + title = summary?.name ?? "打卡点详情" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "二维码", + style: .plain, + target: self, + action: #selector(showQR) + ) + super.viewDidLoad() + viewModel.onChange = { [weak self] in + self?.title = self?.viewModel.selectedDetail?.name ?? self?.summary?.name + self?.reloadTable() + } + } + + override func tableRowCount() -> Int { + guard viewModel.selectedDetail != nil || summary != nil else { return 0 } + return 4 + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let detail = viewModel.selectedDetail ?? summary + guard let detail else { return } + switch indexPath.row { + case 0: cell.configure(title: "名称", subtitle: detail.name) + case 1: cell.configure(title: "地址", subtitle: detail.displayAddress) + case 2: cell.configure(title: "状态", subtitle: detail.statusLabel) + default: cell.configure(title: "创建时间", subtitle: detail.createdAt) + } + } + + override func reloadContent() async { + await viewModel.loadDetail(id: punchPointId, api: services.punchPointAPI) + } + + @objc private func showQR() { + guard let detail = viewModel.selectedDetail ?? summary else { return } + navigationController?.pushViewController( + PunchPointQRViewController(title: detail.name, qrURL: detail.mpQrcode), + animated: true + ) + } +} + +/// 打卡点编辑页。 +final class PunchPointEditorViewController: ModuleTableViewController { + private let viewModel = PunchPointEditorViewModel() + private let nameField = UITextField() + private let punchPointId: Int? + + init(punchPointId: Int?) { + self.punchPointId = punchPointId + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + title = punchPointId == nil ? "新建打卡点" : "编辑打卡点" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "保存", + style: .done, + target: self, + action: #selector(save) + ) + super.viewDidLoad() + nameField.placeholder = "打卡点名称" + nameField.borderStyle = .roundedRect + nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) + tableView.tableHeaderView = nameField + wireViewModel(viewModel) { [weak self] in + if self?.nameField.text?.isEmpty != false { + self?.nameField.text = self?.viewModel.name + } + } + } + + override func tableRowCount() -> Int { 1 } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + cell.configure(title: "地址", subtitle: viewModel.address) + } + + override func reloadContent() async { + guard let punchPointId else { return } + if let detail = try? await services.punchPointAPI.punchPointInfo(id: punchPointId) { + viewModel.apply(detail) + nameField.text = viewModel.name + } + } + + @objc private func save() { + viewModel.name = nameField.text ?? "" + Task { + let success = await viewModel.submit( + scenicId: services.currentScenicId, + api: services.punchPointAPI, + uploadService: services.ossUploadService + ) + if success { navigationController?.popViewController(animated: true) } + } + } +} + +extension PunchPointEditorViewModel: ViewModelBindable {} + +/// 打卡点二维码页。 +final class PunchPointQRViewController: UIViewController { + private let pageTitle: String + private let qrURL: String + + init(title: String, qrURL: String) { + pageTitle = title + self.qrURL = qrURL + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = pageTitle + view.backgroundColor = UIColor(hex: 0xF5F7FA) + + let urlLabel = UILabel() + urlLabel.text = qrURL + urlLabel.numberOfLines = 0 + urlLabel.textAlignment = .center + urlLabel.textColor = AppDesign.textSecondary + view.addSubview(urlLabel) + urlLabel.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(24) + } + } +} diff --git a/suixinkan_ios/Features/PunchPoint/ViewModels/PunchPointViewModels.swift b/suixinkan_ios/Features/PunchPoint/ViewModels/PunchPointViewModels.swift new file mode 100644 index 0000000..792a51e --- /dev/null +++ b/suixinkan_ios/Features/PunchPoint/ViewModels/PunchPointViewModels.swift @@ -0,0 +1,286 @@ +// +// PunchPointViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 打卡点列表 ViewModel,负责筛选、分页、详情加载和删除刷新。 +@MainActor +final class PunchPointListViewModel { + var onChange: (() -> Void)? + var selectedFilter: PunchPointFilter = .all { didSet { onChange?() } } + var items: [PunchPointItem] = [] { didSet { onChange?() } } + var selectedDetail: PunchPointItem? { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var total = 0 { didSet { onChange?() } } + + private var page = 1 + private let pageSize = 20 + + /// 是否还存在下一页数据。 + var hasMore: Bool { + items.count < total + } + + /// 重新加载当前筛选下的打卡点列表。 + func reload(scenicId: Int?, api: any PunchPointServing) async { + guard let scenicId else { + reset() + return + } + page = 1 + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let payload = try await api.punchPointList( + scenicId: scenicId, + status: selectedFilter.rawValue, + page: page, + pageSize: pageSize + ) + items = payload.list + total = payload.total + } catch { + items = [] + total = 0 + errorMessage = error.localizedDescription + } + } + + /// 加载下一页打卡点列表。 + func loadMore(scenicId: Int?, api: any PunchPointServing) async { + guard let scenicId, hasMore, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + let nextPage = page + 1 + defer { isLoadingMore = false } + + do { + let payload = try await api.punchPointList( + scenicId: scenicId, + status: selectedFilter.rawValue, + page: nextPage, + pageSize: pageSize + ) + page = nextPage + items.append(contentsOf: payload.list) + total = payload.total + } catch { + errorMessage = error.localizedDescription + } + } + + /// 切换筛选并重载列表。 + func selectFilter(_ filter: PunchPointFilter, scenicId: Int?, api: any PunchPointServing) async { + guard selectedFilter != filter else { return } + selectedFilter = filter + await reload(scenicId: scenicId, api: api) + } + + /// 加载单个打卡点详情。 + func loadDetail(id: Int, api: any PunchPointServing) async { + errorMessage = nil + do { + selectedDetail = try await api.punchPointInfo(id: id) + } catch { + selectedDetail = items.first { $0.id == id } + errorMessage = error.localizedDescription + } + } + + /// 删除打卡点并刷新当前列表。 + func delete(_ item: PunchPointItem, scenicId: Int?, api: any PunchPointServing) async -> Bool { + do { + try await api.deletePunchPoint(id: item.id) + await reload(scenicId: scenicId, api: api) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 清空列表状态,通常用于缺少当前景区时。 + func reset() { + items = [] + selectedDetail = nil + errorMessage = nil + total = 0 + page = 1 + isLoading = false + isLoadingMore = false + } +} + +/// 打卡点编辑 ViewModel,负责新增、编辑、表单校验和 OSS 图片上传。 +@MainActor +final class PunchPointEditorViewModel { + var onChange: (() -> Void)? + var name = "" { didSet { onChange?() } } + var description = "" { didSet { onChange?() } } + var address = "" { didSet { onChange?() } } + var latitudeText = "" { didSet { onChange?() } } + var longitudeText = "" { didSet { onChange?() } } + var scenicSpotText = "" { didSet { onChange?() } } + var remoteImages: [String] = [] { didSet { onChange?() } } + var localImages: [PunchPointLocalImage] = [] { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + var isSubmitting = false { didSet { onChange?() } } + + private(set) var editingItem: PunchPointItem? + + /// 初始化编辑 ViewModel,可传入已有打卡点作为编辑表单初值。 + init(item: PunchPointItem? = nil) { + editingItem = item + if let item { + apply(item) + } + } + + /// 回填编辑详情,保留同一个 ObservableObject 实例。 + func apply(_ item: PunchPointItem) { + editingItem = item + name = item.name + description = item.description + address = item.region?.address ?? "" + latitudeText = item.region.map { String($0.lat) } ?? "" + longitudeText = item.region.map { String($0.lot) } ?? "" + scenicSpotText = item.scenicSpotStr + remoteImages = item.guideImages + localImages = [] + } + + /// 设置经纬度和地址,通常由定位或地图选点回填。 + func applyLocation(latitude: Double, longitude: Double, address: String) { + latitudeText = String(format: "%.6f", latitude) + longitudeText = String(format: "%.6f", longitude) + self.address = address + if scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scenicSpotText = address + } + } + + /// 添加本地待上传图片。 + func addLocalImages(_ images: [PunchPointLocalImage]) { + localImages.append(contentsOf: images) + } + + /// 删除远程图片。 + func removeRemoteImage(_ url: String) { + remoteImages.removeAll { $0 == url } + } + + /// 删除本地待上传图片。 + func removeLocalImage(id: UUID) { + localImages.removeAll { $0.id == id } + } + + /// 提交新增或编辑表单,图片会先上传 OSS,再提交业务接口。 + func submit( + scenicId: Int?, + api: any PunchPointServing, + uploadService: any OSSUploadServing + ) async -> Bool { + guard !isSubmitting else { return false } + guard let scenicId else { + errorMessage = "缺少当前景区" + return false + } + guard let region = makeRegion() else { return false } + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + errorMessage = "请输入打卡点名称" + return false + } + guard !region.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + errorMessage = "请选择或填写打卡点地址" + return false + } + guard !remoteImages.isEmpty || !localImages.isEmpty else { + errorMessage = "请至少上传一张打卡点图片" + return false + } + + isSubmitting = true + errorMessage = nil + defer { isSubmitting = false } + + do { + let uploadedImages = try await uploadImages(scenicId: scenicId, uploadService: uploadService) + let allImages = remoteImages + uploadedImages + if let editingItem { + try await api.editPunchPoint( + EditPunchPointRequest( + id: editingItem.id, + scenicAreaId: "\(scenicId)", + name: trimmedName, + description: description.trimmingCharacters(in: .whitespacesAndNewlines), + region: region, + scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines), + guideImages: allImages + ) + ) + } else { + try await api.addPunchPoint( + AddPunchPointRequest( + scenicAreaId: "\(scenicId)", + name: trimmedName, + description: description.trimmingCharacters(in: .whitespacesAndNewlines), + region: region, + scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines), + guideImages: allImages + ) + ) + } + remoteImages = allImages + localImages = [] + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 构建提交区域,校验经纬度是否可用。 + private func makeRegion() -> PunchPointRegion? { + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard let lat = Double(latitudeText.trimmingCharacters(in: .whitespacesAndNewlines)), + let lot = Double(longitudeText.trimmingCharacters(in: .whitespacesAndNewlines)) else { + errorMessage = "请选择打卡点坐标" + return nil + } + return PunchPointRegion( + lat: lat, + lot: lot, + address: trimmedAddress, + scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + /// 上传本地图片,并返回 OSS URL 列表。 + private func uploadImages(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { + var uploaded: [String] = [] + for index in localImages.indices { + let local = localImages[index] + let url = try await uploadService.uploadPunchPointImage( + data: local.data, + fileName: local.fileName, + scenicId: scenicId + ) { [weak self] progress in + Task { @MainActor in + self?.localImages[index].progress = progress + } + } + localImages[index].remoteURL = url + uploaded.append(url) + } + return uploaded + } +} diff --git a/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift b/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift new file mode 100644 index 0000000..a1789a9 --- /dev/null +++ b/suixinkan_ios/Features/QueueManagement/API/ScenicQueueAPI.swift @@ -0,0 +1,199 @@ +// +// ScenicQueueAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队管理服务协议,定义列表、动作、设置、二维码和实时 token 能力。 +@MainActor +protocol ScenicQueueServing: AnyObject { + func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData + func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData + func socketToken() async throws -> SocketTokenResponse + func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData + func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData + func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData + func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws + func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws + func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData + func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws + func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData + func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData +} + +@MainActor +/// 排队管理 API,封装 `/api/app/scenic-queue` 下的排队接口。 +final class ScenicQueueAPI { + private let client: APIClient + + /// 初始化排队管理 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取当前打卡点排队统计。 + func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData { + do { + return try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-queue/queue-stats", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)) + ] + ) + ) + } catch APIError.emptyData { + return ScenicQueueStatsData() + } + } + + /// 获取当前打卡点排队列表。 + func scenicQueueHome( + scenicId: Int, + scenicSpotId: Int, + type: Int, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> ScenicQueueHomeData { + do { + return try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-queue/home", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)), + URLQueryItem(name: "type", value: String(type)), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))) + ] + ) + ) + } catch APIError.emptyData { + return ScenicQueueHomeData() + } + } + + /// 获取 WebSocket 订阅 token。 + func socketToken() async throws -> SocketTokenResponse { + try await client.send(APIRequest(method: .get, path: "/api/app/socket-token")) + } + + /// 叫号。 + func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData { + do { + return try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/call", body: ScenicQueueActionRequest(id: id)) + ) + } catch APIError.emptyData { + return ScenicQueueCallData(id: id) + } + } + + /// 标记过号。 + func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData { + do { + return try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/pass", body: ScenicQueueActionRequest(id: id)) + ) + } catch APIError.emptyData { + return ScenicQueuePassData(id: id) + } + } + + /// 标记完成。 + func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData { + do { + return try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/finish", body: ScenicQueueActionRequest(id: id)) + ) + } catch APIError.emptyData { + return ScenicQueueFinishData(id: id) + } + } + + /// 将过号记录重新插回当前排队队列。 + func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/app/scenic-queue/requeue-insert-before", + body: ScenicQueueRequeueInsertBeforeRequest(recordId: recordId, operatorId: operatorId) + ) + ) + } + + /// 标记用户身份/限制排队。 + func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/user-mark", body: request) + ) + } + + /// 获取排队设置。 + func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { + var query = [URLQueryItem(name: "scenic_id", value: String(scenicId))] + if let scenicSpotId { + query.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))) + } + do { + return try await client.send( + APIRequest(method: .get, path: "/api/app/scenic-queue/setting", queryItems: query) + ) + } catch APIError.emptyData { + return ScenicQueueSettingData() + } + } + + /// 保存排队设置。 + func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/app/scenic-queue/save-setting", body: request) + ) + } + + /// 获取取号二维码。 + func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/scenic-queue/shoot-queue-qrcode", + queryItems: [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId)) + ] + ) + ) + } + + /// 获取排队设置变更日志。 + func scenicQueueSettingChangeLog( + scenicId: Int, + scenicSpotId: Int?, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> ScenicQueueSettingChangeLogData { + var query = [ + URLQueryItem(name: "scenic_id", value: String(scenicId)), + URLQueryItem(name: "page", value: String(max(page, 1))), + URLQueryItem(name: "page_size", value: String(max(pageSize, 1))) + ] + if let scenicSpotId { + query.append(URLQueryItem(name: "scenic_spot_id", value: String(scenicSpotId))) + } + do { + return try await client.send( + APIRequest(method: .get, path: "/api/app/scenic-queue/setting-change-log", queryItems: query) + ) + } catch APIError.emptyData { + return ScenicQueueSettingChangeLogData() + } + } +} + +extension ScenicQueueAPI: ScenicQueueServing {} diff --git a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift new file mode 100644 index 0000000..0da1016 --- /dev/null +++ b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueModels.swift @@ -0,0 +1,491 @@ +// +// ScenicQueueModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队统计响应。 +struct ScenicQueueStatsData: Decodable, Equatable { + let queueCount: Int + let avgWaitMin: Double + let time: String + + enum CodingKeys: String, CodingKey { + case queueCount = "queue_count" + case avgWaitMin = "avg_wait_min" + case time + } + + /// 创建排队统计,主要用于空响应兜底。 + init(queueCount: Int = 0, avgWaitMin: Double = 0, time: String = "") { + self.queueCount = queueCount + self.avgWaitMin = avgWaitMin + self.time = time + } + + /// 宽松解码统计字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + queueCount = try container.decodeLossyInt(forKey: .queueCount) ?? 0 + avgWaitMin = try container.decodeLossyDouble(forKey: .avgWaitMin) ?? 0 + time = try container.decodeLossyString(forKey: .time) + } +} + +/// 排队首页响应,兼容 list 为数组或分页对象两种形态。 +struct ScenicQueueHomeData: Decodable, Equatable { + let stats: ScenicQueueHomeStats? + let list: ScenicQueueHomeListBlock? + let time: String? + + enum CodingKeys: String, CodingKey { + case stats + case list + case time + } + + /// 创建排队首页数据,主要用于空响应兜底。 + init(stats: ScenicQueueHomeStats? = nil, list: ScenicQueueHomeListBlock? = nil, time: String? = nil) { + self.stats = stats + self.list = list + self.time = time + } + + /// 宽松解码排队首页数据。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + stats = try container.decodeIfPresent(ScenicQueueHomeStats.self, forKey: .stats) + if let block = try? container.decodeIfPresent(ScenicQueueHomeListBlock.self, forKey: .list) { + list = block + } else if let tickets = try? container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) { + list = ScenicQueueHomeListBlock(list: tickets) + } else { + list = nil + } + time = try container.decodeIfPresent(String.self, forKey: .time) + } +} + +/// 排队首页统计块。 +struct ScenicQueueHomeStats: Decodable, Equatable { + let type: Int + + enum CodingKeys: String, CodingKey { + case type + } + + /// 创建统计块。 + init(type: Int = 0) { + self.type = type + } + + /// 宽松解码统计块。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decodeLossyInt(forKey: .type) ?? 0 + } +} + +/// 排队列表分页块。 +struct ScenicQueueHomeListBlock: Decodable, Equatable { + let list: [ScenicQueueTicket] + let total: Int + let page: Int + let pageSize: Int + + enum CodingKeys: String, CodingKey { + case list + case total + case page + case pageSize = "page_size" + } + + /// 创建分页块。 + init(list: [ScenicQueueTicket] = [], total: Int? = nil, page: Int = 1, pageSize: Int = 20) { + self.list = list + self.total = total ?? list.count + self.page = page + self.pageSize = pageSize + } + + /// 宽松解码分页块。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + list = try container.decodeIfPresent([ScenicQueueTicket].self, forKey: .list) ?? [] + total = try container.decodeLossyInt(forKey: .total) ?? list.count + page = try container.decodeLossyInt(forKey: .page) ?? 1 + pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? max(list.count, 20) + } +} + +/// 单个排队取号记录。 +struct ScenicQueueTicket: Decodable, Equatable, Identifiable { + let id: Int64 + let queueCode: String + let mobile: String + let status: Int + let statusText: String + let waitMin: Int + let aheadCount: Int + let isCalled: Int + let createdAt: String + let calledAt: String + let expiredAt: String + let finishedAt: String + let queueBanLabel: String + let identityTag: String + let queueTime: String + let queueCountToday: Int + let uid: Int64 + let markAsPhotog: Int + let markAsFreelancePhotog: Int + let isMissRequeue: Int + let missRequeueText: String + + var phoneMasked: String { + let digits = mobile.filter(\.isNumber) + if mobile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "--" } + guard digits.count >= 7 else { return mobile } + return "\(digits.prefix(3))****\(digits.suffix(4))" + } + + var dialPhoneDigits: String { + let digits = mobile.filter(\.isNumber) + return digits.isEmpty ? mobile.filter { !$0.isWhitespace } : digits + } + + var queueTimeDisplay: String { + Self.formatQueueTime(queueTime.nonEmptyOrDefault(createdAt)) + } + + var skippedTimeDisplay: String { + Self.formatQueueTime(expiredAt.nonEmptyOrDefault(calledAt.nonEmptyOrDefault(createdAt))) + } + + var shouldShowIdentityTag: Bool { + let text = identityTag.trimmingCharacters(in: .whitespacesAndNewlines) + return !text.isEmpty && text != "普通用户" + } + + var isMissRequeueRecord: Bool { + isMissRequeue == 1 + } + + enum CodingKeys: String, CodingKey { + case id + case queueCode = "queue_code" + case mobile + case status + case statusText = "status_text" + case waitMin = "wait_min" + case aheadCount = "ahead_count" + case isCalled = "is_called" + case createdAt = "created_at" + case calledAt = "called_at" + case expiredAt = "expired_at" + case finishedAt = "finished_at" + case queueBanLabel = "queue_ban_label" + case identityTag = "identity_tag" + case queueTime = "queue_time" + case queueCountToday = "queue_count_today" + case uid + case markAsPhotog = "mark_as_photog" + case markAsFreelancePhotog = "mark_as_freelance_photog" + case isMissRequeue = "is_miss_requeue" + case missRequeueText = "is_miss_requeue_text" + } + + enum AlternateCodingKeys: String, CodingKey { + case queueNo = "queue_no" + case queueNumber = "queue_number" + case code + case phone + case statusName = "status_name" + } + + /// 创建取号记录,主要用于本地状态替换和测试。 + init( + id: Int64, + queueCode: String, + mobile: String, + status: Int, + statusText: String, + waitMin: Int, + aheadCount: Int, + isCalled: Int, + createdAt: String, + calledAt: String, + expiredAt: String, + finishedAt: String, + queueBanLabel: String = "", + identityTag: String = "", + queueTime: String = "", + queueCountToday: Int = 0, + uid: Int64 = 0, + markAsPhotog: Int = 0, + markAsFreelancePhotog: Int = 0, + isMissRequeue: Int = 0, + missRequeueText: String = "" + ) { + self.id = id + self.queueCode = queueCode + self.mobile = mobile + self.status = status + self.statusText = statusText + self.waitMin = waitMin + self.aheadCount = aheadCount + self.isCalled = isCalled + self.createdAt = createdAt + self.calledAt = calledAt + self.expiredAt = expiredAt + self.finishedAt = finishedAt + self.queueBanLabel = queueBanLabel + self.identityTag = identityTag + self.queueTime = queueTime + self.queueCountToday = queueCountToday + self.uid = uid + self.markAsPhotog = markAsPhotog + self.markAsFreelancePhotog = markAsFreelancePhotog + self.isMissRequeue = isMissRequeue + self.missRequeueText = missRequeueText + } + + /// 宽松解码取号记录,兼容 Android 历史字段别名。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let alternate = try decoder.container(keyedBy: AlternateCodingKeys.self) + id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) + queueCode = try container.decodeLossyString(forKey: .queueCode) + .nonEmptyOrDefault( + try alternate.decodeLossyString(forKey: .queueNo) + .nonEmptyOrDefault( + try alternate.decodeLossyString(forKey: .queueNumber) + .nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .code)) + ) + ) + mobile = try container.decodeLossyString(forKey: .mobile) + .nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .phone)) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusText = try container.decodeLossyString(forKey: .statusText) + .nonEmptyOrDefault(try alternate.decodeLossyString(forKey: .statusName)) + waitMin = try container.decodeLossyInt(forKey: .waitMin) ?? 0 + aheadCount = try container.decodeLossyInt(forKey: .aheadCount) ?? 0 + isCalled = try container.decodeLossyInt(forKey: .isCalled) ?? 0 + createdAt = try container.decodeLossyString(forKey: .createdAt) + calledAt = try container.decodeLossyString(forKey: .calledAt) + expiredAt = try container.decodeLossyString(forKey: .expiredAt) + finishedAt = try container.decodeLossyString(forKey: .finishedAt) + queueBanLabel = try container.decodeLossyString(forKey: .queueBanLabel).trimmingCharacters(in: .whitespacesAndNewlines) + identityTag = try container.decodeLossyString(forKey: .identityTag).trimmingCharacters(in: .whitespacesAndNewlines) + queueTime = try container.decodeLossyString(forKey: .queueTime) + queueCountToday = max(try container.decodeLossyInt(forKey: .queueCountToday) ?? 0, 0) + uid = Int64(try container.decodeLossyInt(forKey: .uid) ?? 0) + markAsPhotog = try container.decodeLossyInt(forKey: .markAsPhotog) ?? 0 + markAsFreelancePhotog = try container.decodeLossyInt(forKey: .markAsFreelancePhotog) ?? 0 + isMissRequeue = try container.decodeLossyInt(forKey: .isMissRequeue) ?? 0 + missRequeueText = try container.decodeLossyString(forKey: .missRequeueText).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func formatQueueTime(_ raw: String) -> String { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return "--" } + if text.range(of: #"^\d{2}-\d{2}\s+\d{2}:\d{2}$"#, options: .regularExpression) != nil { + return text + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] { + formatter.dateFormat = format + if let date = formatter.date(from: text) { + formatter.dateFormat = "MM-dd HH:mm" + return formatter.string(from: date) + } + } + return text + } +} + +/// 排队动作请求体。 +struct ScenicQueueActionRequest: Encodable { + let id: Int64 +} + +/// socket token 响应。 +struct SocketTokenResponse: Decodable, Equatable { + let socketToken: String + + enum CodingKeys: String, CodingKey { + case socketToken = "socket_token" + } + + /// 创建 socket token 响应。 + init(socketToken: String = "") { + self.socketToken = socketToken + } + + /// 宽松解码 socket token。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + socketToken = try container.decodeLossyString(forKey: .socketToken) + } +} + +/// 排队动作响应。 +struct ScenicQueueActionData: Decodable, Equatable { + let id: Int64 + let status: Int + let statusText: String + let calledAt: String? + + enum CodingKeys: String, CodingKey { + case id + case status + case statusText = "status_text" + case calledAt = "called_at" + } + + /// 创建动作响应。 + init(id: Int64 = 0, status: Int = 0, statusText: String = "", calledAt: String? = nil) { + self.id = id + self.status = status + self.statusText = statusText + self.calledAt = calledAt + } + + /// 宽松解码动作响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusText = try container.decodeLossyString(forKey: .statusText) + calledAt = try container.decodeIfPresent(String.self, forKey: .calledAt) + } +} + +typealias ScenicQueueCallData = ScenicQueueActionData +typealias ScenicQueuePassData = ScenicQueueActionData +typealias ScenicQueueFinishData = ScenicQueueActionData +typealias QueueItem = ScenicQueueTicket + +/// 重新排队请求体。 +struct ScenicQueueRequeueInsertBeforeRequest: Encodable { + let recordId: Int64 + let operatorId: Int + + enum CodingKeys: String, CodingKey { + case recordId = "record_id" + case operatorId = "operator_id" + } +} + +/// 用户标记请求体。 +struct ScenicQueueUserMarkRequest: Encodable, Equatable { + let uid: Int64 + let scenicId: Int64 + let markAsFreelancePhotog: Int + let queueBanDays: Int? + let operatorId: Int? + + enum CodingKeys: String, CodingKey { + case uid + case scenicId = "scenic_id" + case markAsFreelancePhotog = "mark_as_freelance_photog" + case queueBanDays = "queue_ban_days" + case operatorId = "operator_id" + } +} + +/// 排队列表类型。 +enum QueueListType: Int, CaseIterable, Identifiable { + case queueing = 1 + case passed = 2 + + var id: Int { rawValue } + + /// 分段标题。 + var title: String { + switch self { + case .queueing: + return "当前排队" + case .passed: + return "过号列表" + } + } + + /// 空态文案。 + var emptyText: String { + switch self { + case .queueing: + return "暂无排队" + case .passed: + return "暂无过号" + } + } +} + +/// 远端叫号提示。 +struct ScenicQueueRemoteCallAnnouncement: Identifiable, Equatable { + let id: Int64 + let queueCode: String +} + +private extension KeyedDecodingContainer { + 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 ? "1" : "0" + } + return "" + } + + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return Int(text) ?? Int(Double(text) ?? 0) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? 1 : 0 + } + return nil + } + + 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), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return Double(text) + } + return nil + } +} + +private extension String { + func nonEmptyOrDefault(_ fallback: String) -> String { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? fallback : text + } +} diff --git a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift new file mode 100644 index 0000000..2bfac30 --- /dev/null +++ b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsModels.swift @@ -0,0 +1,368 @@ +// +// ScenicQueueSettingsModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队设置响应。 +struct ScenicQueueSettingData: Decodable, Equatable { + let exists: Bool + let setting: ScenicQueueSettingItem? + let time: String? + + enum CodingKeys: String, CodingKey { + case exists + case setting + case time + } + + /// 创建排队设置响应。 + init(exists: Bool = false, setting: ScenicQueueSettingItem? = nil, time: String? = nil) { + self.exists = exists + self.setting = setting + self.time = time + } + + /// 宽松解码排队设置响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + exists = try container.decodeLossyBool(forKey: .exists) ?? false + setting = try container.decodeIfPresent(ScenicQueueSettingItem.self, forKey: .setting) + time = try container.decodeLossyString(forKey: .time) + } +} + +/// 排队设置实体。 +struct ScenicQueueSettingItem: Decodable, Equatable { + let id: Int64? + let scenicId: Int? + let scenicSpotId: Int? + let scenicSpotName: String + let photoEstimateMin: Int + let photoEstimateSec: Int + let firstNoticeThresholdPos: Int + let firstNoticeSmsEnabled: Int + let firstNoticeCallEnabled: Int + let secondNoticeThresholdPos: Int + let secondNoticeSmsEnabled: Int + let secondNoticeCallEnabled: Int + let countdownBroadcastIntervalSec: Int + let countdownReadableThresholdSec: Int + let queueDistanceMeter: Int + let queueTakeLimit: Int + let missCallRequeueOffset: Int + let showStartShootButton: Int? + let autoCallNextCount: Int? + let businessStartTime: String + let businessEndTime: String + let status: Int + let remark: String + let voiceBroadcasts: [ScenicQueueVoiceBroadcastItem] + let createdAt: String + let updatedAt: String + + enum CodingKeys: String, CodingKey { + case id + case scenicId = "scenic_id" + case scenicSpotId = "scenic_spot_id" + case scenicSpotName = "scenic_spot_name" + case photoEstimateMin = "photo_estimate_min" + case photoEstimateSec = "photo_estimate_sec" + case firstNoticeThresholdPos = "first_notice_threshold_pos" + case firstNoticeSmsEnabled = "first_notice_sms_enabled" + case firstNoticeCallEnabled = "first_notice_call_enabled" + case secondNoticeThresholdPos = "second_notice_threshold_pos" + case secondNoticeSmsEnabled = "second_notice_sms_enabled" + case secondNoticeCallEnabled = "second_notice_call_enabled" + case countdownBroadcastIntervalSec = "countdown_broadcast_interval_sec" + case countdownReadableThresholdSec = "countdown_readable_threshold_sec" + case queueDistanceMeter = "queue_distance_meter" + case queueTakeLimit = "queue_take_limit" + case missCallRequeueOffset = "miss_call_requeue_offset" + case showStartShootButton = "show_start_shoot_button" + case autoCallNextCount = "auto_call_next_count" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + case status + case remark + case voiceBroadcasts = "voice_broadcasts" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + /// 宽松解码排队设置实体。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = (try container.decodeLossyInt(forKey: .id)).map(Int64.init) + scenicId = try container.decodeLossyInt(forKey: .scenicId) + scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + photoEstimateMin = try container.decodeLossyInt(forKey: .photoEstimateMin) ?? 0 + photoEstimateSec = try container.decodeLossyInt(forKey: .photoEstimateSec) ?? 0 + firstNoticeThresholdPos = try container.decodeLossyInt(forKey: .firstNoticeThresholdPos) ?? 0 + firstNoticeSmsEnabled = try container.decodeLossyInt(forKey: .firstNoticeSmsEnabled) ?? 0 + firstNoticeCallEnabled = try container.decodeLossyInt(forKey: .firstNoticeCallEnabled) ?? 0 + secondNoticeThresholdPos = try container.decodeLossyInt(forKey: .secondNoticeThresholdPos) ?? 0 + secondNoticeSmsEnabled = try container.decodeLossyInt(forKey: .secondNoticeSmsEnabled) ?? 0 + secondNoticeCallEnabled = try container.decodeLossyInt(forKey: .secondNoticeCallEnabled) ?? 0 + countdownBroadcastIntervalSec = try container.decodeLossyInt(forKey: .countdownBroadcastIntervalSec) ?? 50 + countdownReadableThresholdSec = try container.decodeLossyInt(forKey: .countdownReadableThresholdSec) ?? 15 + queueDistanceMeter = try container.decodeLossyInt(forKey: .queueDistanceMeter) ?? 0 + queueTakeLimit = try container.decodeLossyInt(forKey: .queueTakeLimit) ?? 0 + missCallRequeueOffset = try container.decodeLossyInt(forKey: .missCallRequeueOffset) ?? 1 + showStartShootButton = try container.decodeLossyInt(forKey: .showStartShootButton) + autoCallNextCount = try container.decodeLossyInt(forKey: .autoCallNextCount) + businessStartTime = try container.decodeLossyString(forKey: .businessStartTime) + businessEndTime = try container.decodeLossyString(forKey: .businessEndTime) + status = try container.decodeLossyInt(forKey: .status) ?? 1 + remark = try container.decodeLossyString(forKey: .remark) + voiceBroadcasts = (try? container.decodeIfPresent([ScenicQueueVoiceBroadcastItem].self, forKey: .voiceBroadcasts)) ?? [] + createdAt = try container.decodeLossyString(forKey: .createdAt) + updatedAt = try container.decodeLossyString(forKey: .updatedAt) + } +} + +/// 排队语音播报预设。 +struct ScenicQueueVoiceBroadcastItem: Codable, Identifiable, Equatable { + var id: String { "\(sortOrder)-\(content)" } + let content: String + let sortOrder: Int + + enum CodingKeys: String, CodingKey { + case content + case sortOrder = "sort_order" + } + + /// 创建语音播报预设。 + init(content: String, sortOrder: Int) { + self.content = content + self.sortOrder = sortOrder + } + + /// 宽松解码语音播报预设。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + content = try container.decodeLossyString(forKey: .content).trimmingCharacters(in: .whitespacesAndNewlines) + sortOrder = try container.decodeLossyInt(forKey: .sortOrder) ?? 0 + } +} + +/// 保存排队设置请求体。 +struct ScenicQueueSaveSettingRequest: Encodable, Equatable { + let scenicId: Int + let scenicSpotId: Int + let photoEstimateMin: Int + let photoEstimateSec: Int + let firstNoticeThresholdPos: Int + let firstNoticeSmsEnabled: Int + let firstNoticeCallEnabled: Int + let secondNoticeThresholdPos: Int + let secondNoticeSmsEnabled: Int + let secondNoticeCallEnabled: Int + let countdownBroadcastIntervalSec: Int + let countdownReadableThresholdSec: Int + let queueDistanceMeter: Int? + let queueTakeLimit: Int? + let missCallRequeueOffset: Int? + let showStartShootButton: Int? + let autoCallNextCount: Int? + let businessStartTime: String + let businessEndTime: String + let voiceBroadcasts: [ScenicQueueVoiceBroadcastItem] + let status: Int + let remark: String? + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case scenicSpotId = "scenic_spot_id" + case photoEstimateMin = "photo_estimate_min" + case photoEstimateSec = "photo_estimate_sec" + case firstNoticeThresholdPos = "first_notice_threshold_pos" + case firstNoticeSmsEnabled = "first_notice_sms_enabled" + case firstNoticeCallEnabled = "first_notice_call_enabled" + case secondNoticeThresholdPos = "second_notice_threshold_pos" + case secondNoticeSmsEnabled = "second_notice_sms_enabled" + case secondNoticeCallEnabled = "second_notice_call_enabled" + case countdownBroadcastIntervalSec = "countdown_broadcast_interval_sec" + case countdownReadableThresholdSec = "countdown_readable_threshold_sec" + case queueDistanceMeter = "queue_distance_meter" + case queueTakeLimit = "queue_take_limit" + case missCallRequeueOffset = "miss_call_requeue_offset" + case showStartShootButton = "show_start_shoot_button" + case autoCallNextCount = "auto_call_next_count" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + case voiceBroadcasts = "voice_broadcasts" + case status + case remark + } +} + +/// 排队设置变更日志响应。 +struct ScenicQueueSettingChangeLogData: Decodable, Equatable { + let total: Int + let page: Int + let pageSize: Int + let list: [ScenicQueueSettingChangeLogItem] + + enum CodingKeys: String, CodingKey { + case total + case page + case pageSize = "page_size" + case list + } + + /// 创建变更日志响应。 + init(total: Int = 0, page: Int = 1, pageSize: Int = 20, list: [ScenicQueueSettingChangeLogItem] = []) { + self.total = total + self.page = page + self.pageSize = pageSize + self.list = list + } + + /// 宽松解码变更日志响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + page = try container.decodeLossyInt(forKey: .page) ?? 1 + pageSize = try container.decodeLossyInt(forKey: .pageSize) ?? 20 + list = (try? container.decodeIfPresent([ScenicQueueSettingChangeLogItem].self, forKey: .list)) ?? [] + } +} + +/// 排队设置变更日志项。 +struct ScenicQueueSettingChangeLogItem: Decodable, Equatable, Identifiable { + let id: Int64 + let scenicSpotName: String + let operatorName: String + let operatorPhoneTail: String + let summary: String + let displayText: String + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case scenicSpotName = "scenic_spot_name" + case operatorName = "operator_name" + case operatorPhoneTail = "operator_phone_tail" + case summary + case displayText = "display_text" + case createdAt = "created_at" + } + + /// 宽松解码变更日志项。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) + scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName) + operatorName = try container.decodeLossyString(forKey: .operatorName) + operatorPhoneTail = try container.decodeLossyString(forKey: .operatorPhoneTail) + summary = try container.decodeLossyString(forKey: .summary) + displayText = try container.decodeLossyString(forKey: .displayText) + createdAt = try container.decodeLossyString(forKey: .createdAt) + } +} + +/// 排队取号二维码响应。 +struct ScenicQueueShootQueueQRCodeData: Decodable, Equatable { + let qrcodeUrl: String + + enum CodingKeys: String, CodingKey { + case qrcodeUrl = "qrcode_url" + } + + /// 创建二维码响应。 + init(qrcodeUrl: String = "") { + self.qrcodeUrl = qrcodeUrl + } + + /// 宽松解码二维码响应。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + qrcodeUrl = try container.decodeLossyString(forKey: .qrcodeUrl) + } +} + +/// 本地排队设置快照,用于服务器无配置或离线时保留关键配置。 +struct ScenicQueueSettingsSnapshot: Codable, Equatable { + var shootMinute: Int = 0 + var shootSecond: Int = 30 + var firstAheadCount: Int = 0 + var firstSms: Bool = false + var firstPhone: Bool = false + var secondAheadCount: Int = 0 + var secondSms: Bool = false + var secondPhone: Bool = false + var broadcastIntervalSec: Int = 50 + var countdownThresholdSec: Int = 15 + var queueDistanceMeter: Int = 0 + var queueTakeLimit: Int = 0 + var missCallRequeueOffset: Int = 1 + var showStartShootingButton: Bool = true + var autoCallAheadCount: Int = 0 + var quickCallButtonEnabled: Bool = false + var prepareCallButtonEnabled: Bool = false + var businessOpen: Bool = true + var businessStartTime: String = "10:00" + var businessEndTime: String = "20:00" + + enum CodingKeys: String, CodingKey { + case shootMinute = "shoot_minute" + case shootSecond = "shoot_second" + case firstAheadCount = "first_ahead_count" + case firstSms = "first_sms" + case firstPhone = "first_phone" + case secondAheadCount = "second_ahead_count" + case secondSms = "second_sms" + case secondPhone = "second_phone" + case broadcastIntervalSec = "broadcast_interval_sec" + case countdownThresholdSec = "countdown_threshold_sec" + case queueDistanceMeter = "queue_distance_meter" + case queueTakeLimit = "queue_take_limit" + case missCallRequeueOffset = "miss_call_requeue_offset" + case showStartShootingButton = "show_start_shooting_button" + case autoCallAheadCount = "auto_call_ahead_count" + case quickCallButtonEnabled = "quick_call_button_enabled" + case prepareCallButtonEnabled = "prepare_call_button_enabled" + case businessOpen = "business_open" + case businessStartTime = "business_start_time" + case businessEndTime = "business_end_time" + } +} + +private extension KeyedDecodingContainer { + 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 ? "1" : "0" } + return "" + } + + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } + if let value = try? decodeIfPresent(String.self, forKey: key), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return Int(text) ?? Int(Double(text) ?? 0) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? 1 : 0 } + return nil + } + + 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), + case let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), + !text.isEmpty { + if ["1", "true", "yes"].contains(text) { return true } + if ["0", "false", "no"].contains(text) { return false } + } + return nil + } +} diff --git a/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift new file mode 100644 index 0000000..792e58a --- /dev/null +++ b/suixinkan_ios/Features/QueueManagement/Models/ScenicQueueSettingsStore.swift @@ -0,0 +1,172 @@ +// +// ScenicQueueSettingsStore.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 排队管理本地设置 key。 +enum ScenicQueueLocalSettings { + static let selectedSpotIdKey = "scenic_queue_selected_spot_id" + static let selectedSpotNameKey = "scenic_queue_selected_spot_name" + static let customTtsTextKey = "scenic_queue_custom_tts_text" + static let settingsSnapshotKey = "scenic_queue_settings_snapshot" + static let photoEstimateSecondsKey = "scenic_queue_photo_estimate_seconds" + static let broadcastIntervalSecondsKey = "scenic_queue_broadcast_interval_seconds" + static let countdownThresholdSecondsKey = "scenic_queue_countdown_threshold_seconds" + static let showStartShootingButtonKey = "scenic_queue_show_start_shooting_button" + static let autoCallAheadCountKey = "scenic_queue_auto_call_ahead_count" + static let quickCallButtonEnabledKey = "scenic_queue_quick_call_button_enabled" + static let prepareCallButtonEnabledKey = "scenic_queue_prepare_call_button_enabled" + static let presetVoicesKey = "scenic_queue_preset_voices" + static let ttsEnabledKey = "scenic_queue_tts_enabled" + static let backgroundPollEnabledKey = "scenic_queue_background_poll_enabled" +} + +/// 排队管理本地设置存储,按用户、景区、打卡点隔离并兼容旧 key。 +enum ScenicQueueSettingsStore { + /// 生成景区作用域 key。 + static func scopedKey(base: String, userId: String?, scenicId: Int?) -> String { + guard let scenicId else { return base } + let user = normalizedUserId(userId) + return "\(base)_user_\(user)_scenic_\(scenicId)" + } + + /// 生成打卡点作用域 key。 + static func scopedSpotKey(base: String, userId: String?, scenicId: Int?, spotId: Int?) -> String { + guard let scenicId, let spotId else { return scopedKey(base: base, userId: userId, scenicId: scenicId) } + let user = normalizedUserId(userId) + return "\(base)_user_\(user)_scenic_\(scenicId)_spot_\(spotId)" + } + + /// 读取已选打卡点 ID。 + static func selectedSpotId(userId: String?, scenicId: Int?) -> Int? { + guard let scenicId else { return legacyPositiveInt(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) } + let key = scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: userId, scenicId: scenicId) + if let value = positiveInt(forKey: key) { return value } + if let legacy = legacyPositiveInt(forKey: ScenicQueueLocalSettings.selectedSpotIdKey) { + UserDefaults.standard.set(legacy, forKey: key) + return legacy + } + return nil + } + + /// 读取已选打卡点名称。 + static func selectedSpotName(userId: String?, scenicId: Int?) -> String { + guard let scenicId else { + return UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.selectedSpotNameKey) ?? "" + } + let key = scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: userId, scenicId: scenicId) + if let value = UserDefaults.standard.string(forKey: key), !value.isEmpty { return value } + let legacy = UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.selectedSpotNameKey) ?? "" + if !legacy.isEmpty { + UserDefaults.standard.set(legacy, forKey: key) + } + return legacy + } + + /// 保存已选打卡点。 + static func saveSelectedSpot(id: Int, name: String, userId: String?, scenicId: Int) { + let defaults = UserDefaults.standard + defaults.set(id, forKey: ScenicQueueLocalSettings.selectedSpotIdKey) + defaults.set(name, forKey: ScenicQueueLocalSettings.selectedSpotNameKey) + defaults.set(id, forKey: scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: userId, scenicId: scenicId)) + defaults.set(name, forKey: scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: userId, scenicId: scenicId)) + } + + /// 读取自定义语音文本。 + static func customTtsText(userId: String?, scenicId: Int?, spotId: Int?) -> String { + guard let scenicId, let spotId else { + return UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.customTtsTextKey) ?? "" + } + let key = scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: userId, scenicId: scenicId, spotId: spotId) + if let value = UserDefaults.standard.string(forKey: key) { return value } + let legacy = UserDefaults.standard.string(forKey: ScenicQueueLocalSettings.customTtsTextKey) ?? "" + if !legacy.isEmpty { + UserDefaults.standard.set(legacy, forKey: key) + } + return legacy + } + + /// 保存自定义语音文本。 + static func saveCustomTtsText(_ text: String, userId: String?, scenicId: Int?, spotId: Int?) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + UserDefaults.standard.set(trimmed, forKey: ScenicQueueLocalSettings.customTtsTextKey) + if let scenicId, let spotId { + UserDefaults.standard.set( + trimmed, + forKey: scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: userId, scenicId: scenicId, spotId: spotId) + ) + } + } + + /// 读取设置快照。 + static func settingsSnapshot(userId: String?, scenicId: Int, spotId: Int) -> ScenicQueueSettingsSnapshot? { + let keys = [ + scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: userId, scenicId: scenicId, spotId: spotId), + "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.settingsSnapshotKey)", + "scenic_\(scenicId)_\(ScenicQueueLocalSettings.settingsSnapshotKey)" + ] + for key in keys { + guard let data = UserDefaults.standard.data(forKey: key), + let snapshot = try? JSONDecoder().decode(ScenicQueueSettingsSnapshot.self, from: data) + else { continue } + return snapshot + } + return nil + } + + /// 保存设置快照。 + static func saveSettingsSnapshot(_ snapshot: ScenicQueueSettingsSnapshot, userId: String?, scenicId: Int, spotId: Int) { + guard let data = try? JSONEncoder().encode(snapshot) else { return } + UserDefaults.standard.set( + data, + forKey: scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: userId, scenicId: scenicId, spotId: spotId) + ) + UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.settingsSnapshotKey)") + } + + /// 读取语音预设。 + static func presetVoices(userId: String?, scenicId: Int?, spotId: Int?) -> [String] { + guard let scenicId, let spotId else { return [] } + let keys = [ + scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: userId, scenicId: scenicId, spotId: spotId), + "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)" + ] + for key in keys { + guard let data = UserDefaults.standard.data(forKey: key), + let voices = try? JSONDecoder().decode([String].self, from: data) + else { continue } + return voices + } + return [] + } + + /// 保存语音预设。 + static func savePresetVoices(_ voices: [String], userId: String?, scenicId: Int, spotId: Int) { + let normalized = Array(voices.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }.prefix(5)) + guard let data = try? JSONEncoder().encode(normalized) else { return } + UserDefaults.standard.set( + data, + forKey: scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: userId, scenicId: scenicId, spotId: spotId) + ) + UserDefaults.standard.set(data, forKey: "scenic_\(scenicId)_spot_\(spotId)_\(ScenicQueueLocalSettings.presetVoicesKey)") + } + + private static func normalizedUserId(_ userId: String?) -> String { + let text = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return text.isEmpty ? "anonymous" : text + } + + private static func positiveInt(forKey key: String) -> Int? { + guard UserDefaults.standard.object(forKey: key) != nil else { return nil } + let value = UserDefaults.standard.integer(forKey: key) + return value > 0 ? value : nil + } + + private static func legacyPositiveInt(forKey key: String) -> Int? { + positiveInt(forKey: key) + } +} diff --git a/suixinkan_ios/Features/QueueManagement/QueueManagement.md b/suixinkan_ios/Features/QueueManagement/QueueManagement.md new file mode 100644 index 0000000..b1107de --- /dev/null +++ b/suixinkan_ios/Features/QueueManagement/QueueManagement.md @@ -0,0 +1,20 @@ +# 排队管理模块 + +## 模块职责 + +`Features/QueueManagement` 承接首页 `/scenic-queue` 和 `queue_management` 权限入口,负责景区打卡点队列列表、叫号、过号、完成、重新排队、用户标记、队列设置、排队二维码和配置日志。 + +## 代码结构 + +- `ScenicQueueAPI`:封装 `/api/app/scenic-queue/...` 和 `/api/app/socket-token`。 +- `QueueManagementViewModel`:管理已保存打卡点门禁、当前排队/过号列表分页、统计、队列动作和页面前台实时监听。 +- `ScenicQueueSettingsViewModel`:管理打卡点设置加载、本地快照兜底、表单校验、保存、二维码和配置日志。 +- `ScenicQueueSettingsStore`:按 `userId + scenicId + spotId` 作用域保存本地排队设置,并兼容旧 key。 +- `Core/Queue`:承接排队 WebSocket、远端叫号去重、语音播报和页面离开后的短轮询运行时。 + +## 业务边界 + +- 列表页从当前账号景区和打卡点上下文读取数据;没有已保存打卡点时只显示设置引导,不自动选择第一个打卡点。 +- 叫号成功后只做本地已叫号标记;过号、完成、重新排队和用户标记成功后刷新当前打卡点列表。 +- 页面可见时由 `QueueManagementViewModel` 维护 WebSocket 监听;页面离开后由 `ScenicQueueRuntime` 按本地开关进行短轮询。 +- 设置保存前校验拍摄时长、通知阈值、播报间隔、读秒阈值、排队范围、排队次数、过号顺延和自定义播报长度。 diff --git a/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift b/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift new file mode 100644 index 0000000..e002d52 --- /dev/null +++ b/suixinkan_ios/Features/QueueManagement/ViewControllers/QueueManagementViewControllers.swift @@ -0,0 +1,87 @@ +// +// QueueManagementViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 排队管理页。 +final class QueueManagementViewController: ModuleTableViewController { + private let viewModel = QueueManagementViewModel() + + override func viewDidLoad() { + title = "排队管理" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "设置", + style: .plain, + target: self, + action: #selector(openSettings) + ) + super.viewDidLoad() + wireViewModel(viewModel) { [weak self] in self?.updateTitle() } + } + + override func tableRowCount() -> Int { viewModel.items.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + cell.configure(title: item.phoneMasked, subtitle: item.statusText, detail: item.queueCode) + } + + override func reloadContent() async { + await viewModel.reload( + api: services.scenicQueueAPI, + scenicId: services.currentScenicId, + userId: services.userId, + spots: services.scenicSpotContext.spots + ) + updateTitle() + } + + private func updateTitle() { + title = "排队 \(viewModel.queueCount)" + } + + @objc private func openSettings() { + navigationController?.pushViewController(ScenicQueueSettingsViewController(), animated: true) + } +} + +extension QueueManagementViewModel: ViewModelBindable {} + +/// 排队设置页。 +final class ScenicQueueSettingsViewController: ModuleTableViewController { + private let viewModel = ScenicQueueSettingsViewModel() + + override func viewDidLoad() { + title = "排队设置" + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { viewModel.scenicSpots.count } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let spot = viewModel.scenicSpots[indexPath.row] + let selected = viewModel.selectedSpotId == spot.id + cell.configure(title: spot.name, subtitle: selected ? "当前打卡点" : nil) + cell.accessoryType = selected ? .checkmark : .none + } + + override func didSelectTableRow(at indexPath: IndexPath) { + viewModel.selectedSpotId = viewModel.scenicSpots[indexPath.row].id + } + + override func reloadContent() async { + await viewModel.load( + api: services.scenicQueueAPI, + scenicId: services.currentScenicId, + userId: services.userId, + spots: services.scenicSpotContext.spots + ) + } +} + +extension ScenicQueueSettingsViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift b/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift new file mode 100644 index 0000000..4a1c696 --- /dev/null +++ b/suixinkan_ios/Features/QueueManagement/ViewModels/QueueManagementViewModels.swift @@ -0,0 +1,882 @@ +// +// QueueManagementViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import Photos +import UIKit + +@MainActor +/// 排队管理列表 ViewModel,负责打卡点门禁、列表分页、动作和实时事件。 +final class QueueManagementViewModel { + var onChange: (() -> Void)? + var loading = false { didSet { onChange?() } } + var loadingMore = false { didSet { onChange?() } } + var items: [QueueItem] = [] { didSet { onChange?() } } + var scenicSpots: [ScenicSpotItem] = [] { didSet { onChange?() } } + var selectedSpotId: Int? { didSet { onChange?() } } + var selectedListType: QueueListType = .queueing { didSet { onChange?() } } + var queueCount = 0 { didSet { onChange?() } } + var avgWaitMin = 0.0 { didSet { onChange?() } } + var totalCount = 0 { didSet { onChange?() } } + var hasMore = false { didSet { onChange?() } } + var page = 1 { didSet { onChange?() } } + var lastSyncTimeText = "--" { didSet { onChange?() } } + var queueGatePassed = false { didSet { onChange?() } } + var selectedSpotName = "--" { didSet { onChange?() } } + var showStartShootingButton = true { didSet { onChange?() } } + var autoCallAheadCount = 0 { didSet { onChange?() } } + var quickCallButtonEnabled = false { didSet { onChange?() } } + var prepareCallButtonEnabled = false { didSet { onChange?() } } + var remoteCallAnnouncement: ScenicQueueRemoteCallAnnouncement? { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 10 + private let socketClient = ScenicQueueSocketClient() + private var realtimeSpotId: Int? + private var realtimeScenicId: Int? + private var currentUserId: String? + private var currentScenicId: Int? + private var pendingRemoteCalledRecordIds = Set() + private var handledRemoteCallEventIds = Set() + + /// 待处理排队数量。 + var pendingCount: Int { + items.filter { $0.status < 7 && !$0.statusText.contains("完成") }.count + } + + /// 平均等待时间文案。 + var avgWaitMinText: String { + avgWaitMin.rounded() == avgWaitMin ? "\(Int(avgWaitMin))" : String(format: "%.1f", avgWaitMin) + } + + /// 当前打卡点名称。 + var currentSpotName: String { + scenicSpots.first(where: { $0.id == selectedSpotId })?.name + ?? selectedSpotName.nonEmptyOrDefault(queueGatePassed ? "--" : "未设置打卡点") + } + + /// 重新加载排队数据。没有已保存打卡点时只展示设置引导,不自动选第一个。 + func reload(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async { + guard let scenicId else { + reset() + return + } + + loading = true + errorMessage = nil + defer { loading = false } + + currentUserId = userId + currentScenicId = scenicId + scenicSpots = spots + + let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) + if let savedSpotId, spots.isEmpty || spots.contains(where: { $0.id == savedSpotId }) { + selectedSpotId = savedSpotId + selectedSpotName = ScenicQueueSettingsStore.selectedSpotName(userId: userId, scenicId: scenicId) + .nonEmptyOrDefault(spots.first(where: { $0.id == savedSpotId })?.name ?? "--") + queueGatePassed = true + await syncQueueSettingFromServerIfMatchesLocal(api: api, userId: userId, scenicId: scenicId, spotId: savedSpotId) + refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: savedSpotId) + } else { + selectedSpotId = nil + selectedSpotName = "--" + queueGatePassed = false + clearQueueData() + return + } + + do { + try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1) + } catch { + clearQueueData() + errorMessage = error.localizedDescription + } + } + + /// 切换列表类型并加载第一页。 + func selectListType(_ type: QueueListType, api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard selectedListType != type else { return } + selectedListType = type + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + /// 重新加载当前打卡点列表。 + func reloadCurrentSpot(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard let scenicId else { return } + loading = true + errorMessage = nil + defer { loading = false } + do { + try await loadPage(api: api, scenicId: scenicId, userId: userId, page: 1) + } catch { + clearQueueData() + errorMessage = error.localizedDescription + } + } + + /// 加载下一页。 + func loadMore(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard let scenicId, hasMore, !loadingMore else { return } + loadingMore = true + defer { loadingMore = false } + do { + try await loadPage(api: api, scenicId: scenicId, userId: userId, page: page + 1) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 叫号,成功后仅本地标记为已叫号。 + func callQueue(api: any ScenicQueueServing, id: Int64) async throws { + let data = try await api.scenicQueueCall(id: id) + markQueueCalled(id: id, statusText: data.statusText) + } + + /// 标记过号并刷新列表。 + func passQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws { + _ = try await api.scenicQueuePass(id: id) + items.removeAll { $0.id == id } + queueCount = max(queueCount - 1, 0) + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + /// 标记完成并刷新列表。 + func finishQueue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64) async throws -> ScenicQueueFinishData { + let data = try await api.scenicQueueFinish(id: id) + items.removeAll { $0.id == id } + queueCount = max(queueCount - 1, 0) + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + return data + } + + /// 重新排队并切回当前排队列表。 + func requeue(api: any ScenicQueueServing, scenicId: Int?, userId: String?, id: Int64, operatorId: Int) async throws { + try await api.scenicQueueRequeueInsertBefore(recordId: id, operatorId: operatorId) + selectedListType = .queueing + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + /// 标记用户身份/限制排队。 + func userMark(api: any ScenicQueueServing, scenicId: Int?, userId: String?, request: ScenicQueueUserMarkRequest) async throws { + try await api.scenicQueueUserMark(request) + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + /// 启动页面前台实时监听。 + func startRealtime(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard queueGatePassed, let scenicId, let scenicSpotId = selectedSpotId else { + stopRealtime() + return + } + guard realtimeScenicId != scenicId || realtimeSpotId != scenicSpotId else { return } + stopRealtime() + do { + let token = try await api.socketToken().socketToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !token.isEmpty else { return } + realtimeScenicId = scenicId + realtimeSpotId = scenicSpotId + socketClient.connect(socketToken: token, scenicSpotId: scenicSpotId) { [weak self] message in + Task { @MainActor in + await self?.handleSocketMessage(message, api: api, scenicId: scenicId, userId: userId) + } + } + } catch { + errorMessage = error.localizedDescription + } + } + + /// 停止页面实时监听。 + func stopRealtime() { + socketClient.disconnect() + realtimeScenicId = nil + realtimeSpotId = nil + pendingRemoteCalledRecordIds.removeAll() + handledRemoteCallEventIds.removeAll() + } + + /// 当前拍摄预计时长秒数。 + var shootingDurationSeconds: Int { + guard let scenicId = currentScenicId, let selectedSpotId, + let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId) + else { + return max(UserDefaults.standard.integer(forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey), 0) + } + return max(snapshot.shootMinute * 60 + snapshot.shootSecond, 0) + } + + private func handleSocketMessage(_ message: ScenicQueueSocketMessage, api: any ScenicQueueServing, scenicId: Int, userId: String?) async { + guard message.isScenicQueueEvent, + let params = message.data?.params, + let changedSpotId = params.scenicSpotId, + let selectedSpotId, + changedSpotId == Int64(selectedSpotId) + else { return } + + switch message.data?.action { + case ScenicQueueSocketMessage.queueUpdatedAction: + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + case ScenicQueueSocketMessage.ticketCalledAction: + await handleRemoteTicketCalled(params: params, api: api, scenicId: scenicId, userId: userId) + default: + return + } + } + + private func handleRemoteTicketCalled(params: ScenicQueueSocketParams, api: any ScenicQueueServing, scenicId: Int, userId: String?) async { + guard let recordId = params.recordId, recordId > 0 else { return } + if let operatorUid = params.operatorUid, + let myUid = Int64(userId ?? ""), + operatorUid == myUid { + return + } + let eventId = params.eventId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !eventId.isEmpty, !handledRemoteCallEventIds.insert(eventId).inserted { + return + } + pendingRemoteCalledRecordIds.insert(recordId) + guard let selectedSpotId else { return } + _ = try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: selectedSpotId) + let tickets = (try? await loadQueueingTickets(api: api, scenicId: scenicId, scenicSpotId: selectedSpotId)) ?? [] + if selectedListType == .queueing { + items = tickets + totalCount = max(totalCount, tickets.count) + hasMore = tickets.count < totalCount + } + consumePendingRemoteCalledTickets(from: tickets) + await reloadCurrentSpot(api: api, scenicId: scenicId, userId: userId) + } + + private func loadQueueingTickets(api: any ScenicQueueServing, scenicId: Int, scenicSpotId: Int) async throws -> [QueueItem] { + let home = try await api.scenicQueueHome( + scenicId: scenicId, + scenicSpotId: scenicSpotId, + type: QueueListType.queueing.rawValue, + page: 1, + pageSize: pageSize + ) + return home.list?.list ?? [] + } + + private func consumePendingRemoteCalledTickets(from tickets: [QueueItem]) { + guard let ticket = tickets.first(where: { + pendingRemoteCalledRecordIds.contains($0.id) && ($0.isCalled == 1 || $0.status == 1 || $0.statusText.contains("已叫号")) + }) else { + return + } + pendingRemoteCalledRecordIds.remove(ticket.id) + remoteCallAnnouncement = ScenicQueueRemoteCallAnnouncement(id: ticket.id, queueCode: ticket.queueCode) + } + + private func loadPage(api: any ScenicQueueServing, scenicId: Int, userId: String?, page targetPage: Int) async throws { + guard let scenicSpotId = selectedSpotId else { return } + let home = try await api.scenicQueueHome( + scenicId: scenicId, + scenicSpotId: scenicSpotId, + type: selectedListType.rawValue, + page: targetPage, + pageSize: pageSize + ) + let stats = (try? await api.scenicQueueStats(scenicId: scenicId, scenicSpotId: scenicSpotId)) ?? ScenicQueueStatsData() + let block = home.list + let nextList = block?.list ?? [] + + if targetPage <= 1 { + items = nextList + } else { + items.append(contentsOf: nextList) + } + page = targetPage + totalCount = block?.total ?? items.count + hasMore = page * pageSize < totalCount + queueCount = stats.queueCount + avgWaitMin = stats.avgWaitMin + let syncText = stats.time.isEmpty ? (home.time ?? "") : stats.time + lastSyncTimeText = syncText.isEmpty ? "--" : syncText + refreshShootingCallConfig(userId: userId, scenicId: scenicId, spotId: scenicSpotId) + } + + private func markQueueCalled(id: Int64, statusText: String) { + let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines) + items = items.map { item in + guard item.id == id else { return item } + return ScenicQueueTicket( + id: item.id, + queueCode: item.queueCode, + mobile: item.mobile, + status: max(item.status, 1), + statusText: trimmedStatus.nonEmptyOrDefault(item.statusText.contains("已叫号") ? item.statusText : "已叫号"), + waitMin: item.waitMin, + aheadCount: item.aheadCount, + isCalled: 1, + createdAt: item.createdAt, + calledAt: item.calledAt, + expiredAt: item.expiredAt, + finishedAt: item.finishedAt, + queueBanLabel: item.queueBanLabel, + identityTag: item.identityTag, + queueTime: item.queueTime, + queueCountToday: item.queueCountToday, + uid: item.uid, + markAsPhotog: item.markAsPhotog, + markAsFreelancePhotog: item.markAsFreelancePhotog, + isMissRequeue: item.isMissRequeue, + missRequeueText: item.missRequeueText + ) + } + } + + private func reset() { + stopRealtime() + loading = false + loadingMore = false + scenicSpots = [] + selectedSpotId = nil + selectedSpotName = "--" + queueGatePassed = false + selectedListType = .queueing + currentScenicId = nil + clearQueueData() + } + + private func clearQueueData() { + loadingMore = false + items = [] + queueCount = 0 + avgWaitMin = 0 + totalCount = 0 + hasMore = false + page = 1 + lastSyncTimeText = "--" + } + + private func refreshShootingCallConfig(userId: String?, scenicId: Int, spotId: Int) { + let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot() + showStartShootingButton = snapshot.showStartShootingButton + autoCallAheadCount = min(max(snapshot.autoCallAheadCount, 0), 5) + quickCallButtonEnabled = snapshot.quickCallButtonEnabled + prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled + } + + private func syncQueueSettingFromServerIfMatchesLocal(api: any ScenicQueueServing, userId: String?, scenicId: Int, spotId: Int) async { + guard let data = try? await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: spotId), + data.exists, + let setting = data.setting + else { return } + if let settingScenicId = setting.scenicId, settingScenicId != scenicId { return } + if let settingSpotId = setting.scenicSpotId, settingSpotId != spotId { return } + + let local = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: spotId) ?? ScenicQueueSettingsSnapshot() + let snapshot = ScenicQueueSettingsSnapshot( + shootMinute: min(max(setting.photoEstimateMin, 0), 30), + shootSecond: min(max(setting.photoEstimateSec, 0), 59), + firstAheadCount: min(max(setting.firstNoticeThresholdPos, 0), 50), + firstSms: setting.firstNoticeSmsEnabled == 1, + firstPhone: setting.firstNoticeCallEnabled == 1, + secondAheadCount: min(max(setting.secondNoticeThresholdPos, 0), 50), + secondSms: setting.secondNoticeSmsEnabled == 1, + secondPhone: setting.secondNoticeCallEnabled == 1, + broadcastIntervalSec: ScenicQueueSettingsViewModel.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec), + countdownThresholdSec: ScenicQueueSettingsViewModel.coercedCountdownThreshold(setting.countdownReadableThresholdSec), + queueDistanceMeter: min(max(setting.queueDistanceMeter, 0), 9_999_990), + queueTakeLimit: min(max(setting.queueTakeLimit, 0), 999_999), + missCallRequeueOffset: min(max(setting.missCallRequeueOffset, 1), 9_999), + showStartShootingButton: (setting.showStartShootButton ?? (local.showStartShootingButton ? 1 : 0)) == 1, + autoCallAheadCount: min(max(setting.autoCallNextCount ?? local.autoCallAheadCount, 0), 5), + quickCallButtonEnabled: local.quickCallButtonEnabled, + prepareCallButtonEnabled: local.prepareCallButtonEnabled, + businessOpen: setting.status == 1, + businessStartTime: setting.businessStartTime, + businessEndTime: setting.businessEndTime + ) + ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: userId, scenicId: scenicId, spotId: spotId) + ScenicQueueSettingsStore.saveSelectedSpot( + id: spotId, + name: setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName), + userId: userId, + scenicId: scenicId + ) + if !setting.remark.isEmpty, setting.remark != "A0001" { + ScenicQueueSettingsStore.saveCustomTtsText(setting.remark, userId: userId, scenicId: scenicId, spotId: spotId) + } + let voices = setting.voiceBroadcasts + .sorted { $0.sortOrder < $1.sortOrder } + .map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if !voices.isEmpty { + ScenicQueueSettingsStore.savePresetVoices(Array(voices.prefix(5)), userId: userId, scenicId: scenicId, spotId: spotId) + } + selectedSpotName = setting.scenicSpotName.nonEmptyOrDefault(selectedSpotName) + } +} + +@MainActor +/// 排队设置 ViewModel,负责设置读取、校验、保存、二维码和日志。 +final class ScenicQueueSettingsViewModel { + var onChange: (() -> Void)? + var loading = false { didSet { onChange?() } } + var message: String? { didSet { onChange?() } } + var scenicSpots: [ScenicSpotItem] = [] { didSet { onChange?() } } + var selectedSpotId: Int? { didSet { onChange?() } } + var photoEstimateMin = "0" { didSet { onChange?() } } + var photoEstimateSec = "30" { didSet { onChange?() } } + var firstAhead = "0" { didSet { onChange?() } } + var firstSms = false { didSet { onChange?() } } + var firstPhone = false { didSet { onChange?() } } + var secondAhead = "0" { didSet { onChange?() } } + var secondSms = false { didSet { onChange?() } } + var secondPhone = false { didSet { onChange?() } } + var broadcastIntervalSec = "50" { didSet { onChange?() } } + var countdownThresholdSec = "15" { didSet { onChange?() } } + var maxQueueRangeKm = "0.00" { didSet { onChange?() } } + var localQueueLimit = "0" { didSet { onChange?() } } + var localPassDelay = "1" { didSet { onChange?() } } + var showStartShootingButton = true { didSet { onChange?() } } + var autoCallAheadCount = "0" { didSet { onChange?() } } + var queueOpen = true { didSet { onChange?() } } + var businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 10, minute: 0) { didSet { onChange?() } } + var businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 20, minute: 0) { didSet { onChange?() } } + var customTtsText = "" { didSet { onChange?() } } + var presetVoices: [String] = [] { didSet { onChange?() } } + var quickCallButtonEnabled = false { didSet { onChange?() } } + var prepareCallButtonEnabled = false { didSet { onChange?() } } + var logs: [ScenicQueueSettingChangeLogItem] = [] { didSet { onChange?() } } + var qrcodeURL = "" { didSet { onChange?() } } + + private let speaker = ScenicQueueSpeechService() + private var currentUserId: String? + private var currentScenicId: Int? + + /// 加载设置。设置页允许自动选中第一个打卡点。 + func load(api: any ScenicQueueServing, scenicId: Int?, userId: String?, spots: [ScenicSpotItem]) async { + guard let scenicId else { + resetSettingsState() + message = "请先选择景区" + return + } + currentUserId = userId + currentScenicId = scenicId + scenicSpots = spots + message = nil + loading = true + defer { loading = false } + logs = [] + + let savedSpotId = ScenicQueueSettingsStore.selectedSpotId(userId: userId, scenicId: scenicId) + selectedSpotId = savedSpotId.flatMap { id in spots.first(where: { $0.id == id })?.id ?? id } ?? selectedSpotId ?? spots.first?.id + guard selectedSpotId != nil else { return } + await loadSelectedSpotSetting(api: api, scenicId: scenicId, userId: userId) + } + + /// 加载当前选中打卡点设置。 + func loadSelectedSpotSetting(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async { + guard let scenicId, let selectedSpotId else { return } + currentUserId = userId + currentScenicId = scenicId + presetVoices = ScenicQueueSettingsStore.presetVoices(userId: userId, scenicId: scenicId, spotId: selectedSpotId) + let savedCustomText = ScenicQueueSettingsStore.customTtsText(userId: userId, scenicId: scenicId, spotId: selectedSpotId) + if !savedCustomText.isEmpty, savedCustomText != "A0001" { + customTtsText = savedCustomText + } + if let snapshot = ScenicQueueSettingsStore.settingsSnapshot(userId: userId, scenicId: scenicId, spotId: selectedSpotId) { + apply(snapshot) + } + do { + let setting = try await api.scenicQueueSetting(scenicId: scenicId, scenicSpotId: selectedSpotId) + if setting.exists, let item = setting.setting { + apply(item) + ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot(), userId: userId, scenicId: scenicId, spotId: selectedSpotId) + } + } catch { + message = error.localizedDescription + } + } + + /// 加载设置变更日志。 + func loadSettingChangeLogs(api: any ScenicQueueServing, scenicId: Int?) async { + guard let scenicId else { + message = "请先选择景区" + return + } + do { + let data = try await api.scenicQueueSettingChangeLog(scenicId: scenicId, scenicSpotId: selectedSpotId, page: 1, pageSize: 20) + logs = data.list + } catch { + message = error.localizedDescription + } + } + + /// 获取排队二维码 URL。 + func fetchQRCode(api: any ScenicQueueServing, scenicId: Int?) async throws -> String { + guard let scenicId else { throw APIError.networkFailed("请先选择景区") } + guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") } + let data = try await api.scenicQueueShootQueueQRCode(scenicId: scenicId, scenicSpotId: selectedSpotId) + let url = data.qrcodeUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { throw APIError.networkFailed("服务端未返回二维码图片地址") } + qrcodeURL = url + return url + } + + /// 保存二维码图片到系统相册。 + func saveQRCodeToPhotoLibrary(qrcodeURL: String) async throws { + let trimmed = qrcodeURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw APIError.networkFailed("服务端未返回二维码图片地址") + } + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + guard status == .authorized || status == .limited else { + throw APIError.networkFailed("请在系统设置中允许保存图片到相册") + } + let image = try await loadQRCodeImage(from: trimmed) + try await PHPhotoLibrary.shared().performChanges { + PHAssetChangeRequest.creationRequestForAsset(from: image) + } + } + + /// 保存排队设置。 + func save(api: any ScenicQueueServing, scenicId: Int?, userId: String?) async throws { + guard let scenicId else { throw APIError.networkFailed("请先选择景区") } + guard let selectedSpotId else { throw APIError.networkFailed("请选择打卡点") } + currentUserId = userId + currentScenicId = scenicId + let min = try AppFormValidator.rangedInteger(photoEstimateMin, name: "拍摄时长分钟", range: 0...30) + let sec = try AppFormValidator.rangedInteger(photoEstimateSec, name: "拍摄时长秒", range: 0...59) + guard min > 0 || sec > 0 else { throw APIError.networkFailed("拍摄时长须大于 0") } + let first = try AppFormValidator.rangedInteger(firstAhead, name: "第一次通知阈值", range: 0...50) + let second = try AppFormValidator.rangedInteger(secondAhead, name: "第二次通知阈值", range: 0...50) + try AppFormValidator.validateQueueNoticeThresholds(first: first, second: second) + let interval = try AppFormValidator.rangedInteger(broadcastIntervalSec, name: "播报间隔时间", range: 40...60) + let countdown = try AppFormValidator.rangedInteger(countdownThresholdSec, name: "进入读秒倒计时阈值", range: 10...30) + let autoCallAhead = try AppFormValidator.rangedInteger(autoCallAheadCount, name: "自动叫号后续几位", range: 0...5) + maxQueueRangeKm = try normalizedMaxQueueRangeForDisplay() + let queueDistanceMeter = try queueDistanceMeterForSubmit() + let queueTakeLimit = try AppFormValidator.rangedInteger(localQueueLimit, name: "排队次数限制", range: 0...999_999) + let missCallRequeueOffset = try AppFormValidator.rangedInteger(localPassDelay, name: "过号顺延", range: 1...9_999) + guard customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).count <= 255 else { + throw APIError.networkFailed("自定义播报文本最多 255 个字") + } + + loading = true + defer { loading = false } + try await api.scenicQueueSaveSetting(ScenicQueueSaveSettingRequest( + scenicId: scenicId, + scenicSpotId: selectedSpotId, + photoEstimateMin: min, + photoEstimateSec: sec, + firstNoticeThresholdPos: first, + firstNoticeSmsEnabled: firstSms ? 1 : 0, + firstNoticeCallEnabled: firstPhone ? 1 : 0, + secondNoticeThresholdPos: second, + secondNoticeSmsEnabled: secondSms ? 1 : 0, + secondNoticeCallEnabled: secondPhone ? 1 : 0, + countdownBroadcastIntervalSec: interval, + countdownReadableThresholdSec: countdown, + queueDistanceMeter: queueDistanceMeter, + queueTakeLimit: queueTakeLimit, + missCallRequeueOffset: missCallRequeueOffset, + showStartShootButton: showStartShootingButton ? 1 : 0, + autoCallNextCount: autoCallAhead, + businessStartTime: Self.businessTimePayload(businessStartTime), + businessEndTime: Self.businessTimePayload(businessEndTime), + voiceBroadcasts: voiceBroadcastsForSavePayload(), + status: queueOpen ? 1 : 0, + remark: customTtsText.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + )) + let spotName = scenicSpots.first(where: { $0.id == selectedSpotId })?.name ?? "" + ScenicQueueSettingsStore.saveSelectedSpot(id: selectedSpotId, name: spotName, userId: userId, scenicId: scenicId) + saveTimingSnapshot(min: min, sec: sec, interval: interval, countdown: countdown) + saveShootingCallSettings(autoCallAhead: autoCallAhead) + saveCustomTextLocally() + ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: userId, scenicId: scenicId, spotId: selectedSpotId) + ScenicQueueSettingsStore.saveSettingsSnapshot(makeSnapshot( + min: min, + sec: sec, + first: first, + second: second, + interval: interval, + countdown: countdown, + queueDistanceMeter: queueDistanceMeter, + queueTakeLimit: queueTakeLimit, + missCallRequeueOffset: missCallRequeueOffset, + autoCallAhead: autoCallAhead + ), userId: userId, scenicId: scenicId, spotId: selectedSpotId) + } + + /// 保存自定义文本为本地预设。 + func saveCustomTextAsPreset() { + let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines) + guard text.count <= 255 else { + message = "自定义播报文本最多 255 个字" + return + } + saveCustomTextLocally() + if !text.isEmpty, !presetVoices.contains(text), presetVoices.count < 5 { + presetVoices.append(text) + } + if let scenicId = currentScenicId, let selectedSpotId { + ScenicQueueSettingsStore.savePresetVoices(presetVoices, userId: currentUserId, scenicId: scenicId, spotId: selectedSpotId) + } + } + + /// 更新最大排队范围输入。 + func updateMaxQueueRange(_ value: String) { + maxQueueRangeKm = AppFormValidator.sanitizedMoneyInput(value, maxDecimalPlaces: 2) + } + + /// 播放测试音。 + func playTestSound() { + speaker.speak("蓝牙音响测试音,请确认外放音量是否合适。") + } + + /// 播放自定义播报文本。 + func playCustomText() { + let text = customTtsText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + message = "请输入要播报的文字" + return + } + speaker.speak(text) + } + + /// 删除预设语音。 + func deletePresetVoice(at index: Int) { + guard presetVoices.indices.contains(index) else { return } + presetVoices.remove(at: index) + } + + private func resetSettingsState() { + loading = false + scenicSpots = [] + selectedSpotId = nil + photoEstimateMin = "0" + photoEstimateSec = "30" + firstAhead = "0" + firstSms = false + firstPhone = false + secondAhead = "0" + secondSms = false + secondPhone = false + broadcastIntervalSec = "50" + countdownThresholdSec = "15" + maxQueueRangeKm = "0.00" + localQueueLimit = "0" + localPassDelay = "1" + showStartShootingButton = true + autoCallAheadCount = "0" + queueOpen = true + businessStartTime = Self.businessTime(hour: 10, minute: 0) + businessEndTime = Self.businessTime(hour: 20, minute: 0) + customTtsText = "" + logs = [] + presetVoices = [] + quickCallButtonEnabled = false + prepareCallButtonEnabled = false + qrcodeURL = "" + } + + private func apply(_ setting: ScenicQueueSettingItem?) { + guard let setting else { return } + if let spotId = setting.scenicSpotId { selectedSpotId = spotId } + photoEstimateMin = "\(setting.photoEstimateMin)" + photoEstimateSec = "\(setting.photoEstimateSec)" + firstAhead = "\(setting.firstNoticeThresholdPos)" + firstSms = setting.firstNoticeSmsEnabled == 1 + firstPhone = setting.firstNoticeCallEnabled == 1 + secondAhead = "\(setting.secondNoticeThresholdPos)" + secondSms = setting.secondNoticeSmsEnabled == 1 + secondPhone = setting.secondNoticeCallEnabled == 1 + broadcastIntervalSec = "\(Self.coercedBroadcastInterval(setting.countdownBroadcastIntervalSec))" + countdownThresholdSec = "\(Self.coercedCountdownThreshold(setting.countdownReadableThresholdSec))" + maxQueueRangeKm = Self.queueDistanceDisplay(meters: setting.queueDistanceMeter) + localQueueLimit = "\(min(max(setting.queueTakeLimit, 0), 999_999))" + localPassDelay = "\(min(max(setting.missCallRequeueOffset, 1), 9_999))" + if let showStartShootButton = setting.showStartShootButton { showStartShootingButton = showStartShootButton == 1 } + if let autoCallNextCount = setting.autoCallNextCount { autoCallAheadCount = "\(min(max(autoCallNextCount, 0), 5))" } + queueOpen = setting.status == 1 + businessStartTime = Self.parseBusinessTime(setting.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0)) + businessEndTime = Self.parseBusinessTime(setting.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0)) + if !setting.remark.isEmpty, setting.remark != "A0001" { customTtsText = setting.remark } + let voices = setting.voiceBroadcasts + .sorted { $0.sortOrder < $1.sortOrder } + .map { $0.content.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if !voices.isEmpty { presetVoices = Array(voices.prefix(5)) } + } + + private func apply(_ snapshot: ScenicQueueSettingsSnapshot) { + photoEstimateMin = "\(min(max(snapshot.shootMinute, 0), 30))" + photoEstimateSec = "\(min(max(snapshot.shootSecond, 0), 59))" + firstAhead = "\(min(max(snapshot.firstAheadCount, 0), 50))" + firstSms = snapshot.firstSms + firstPhone = snapshot.firstPhone + secondAhead = "\(min(max(snapshot.secondAheadCount, 0), 50))" + secondSms = snapshot.secondSms + secondPhone = snapshot.secondPhone + broadcastIntervalSec = "\(Self.coercedBroadcastInterval(snapshot.broadcastIntervalSec))" + countdownThresholdSec = "\(Self.coercedCountdownThreshold(snapshot.countdownThresholdSec))" + maxQueueRangeKm = Self.queueDistanceDisplay(meters: snapshot.queueDistanceMeter) + localQueueLimit = "\(min(max(snapshot.queueTakeLimit, 0), 999_999))" + localPassDelay = "\(min(max(snapshot.missCallRequeueOffset, 1), 9_999))" + showStartShootingButton = snapshot.showStartShootingButton + autoCallAheadCount = "\(min(max(snapshot.autoCallAheadCount, 0), 5))" + quickCallButtonEnabled = snapshot.quickCallButtonEnabled + prepareCallButtonEnabled = snapshot.prepareCallButtonEnabled + queueOpen = snapshot.businessOpen + businessStartTime = Self.parseBusinessTime(snapshot.businessStartTime, fallback: Self.businessTime(hour: 10, minute: 0)) + businessEndTime = Self.parseBusinessTime(snapshot.businessEndTime, fallback: Self.businessTime(hour: 20, minute: 0)) + } + + private func normalizedMaxQueueRangeForDisplay() throws -> String { + let normalized = AppFormValidator.normalizedMoneyForSubmit(maxQueueRangeKm) + guard !normalized.isEmpty, + normalized.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil, + let value = Decimal(string: normalized, locale: Locale(identifier: "en_US_POSIX")) + else { + throw APIError.networkFailed("允许排队的最大范围格式不正确") + } + let max = Decimal(999_999) / Decimal(100) + guard value >= 0, value <= max else { + throw APIError.networkFailed("允许排队的最大范围须在 0~9999.99") + } + return String(format: "%.2f", NSDecimalNumber(decimal: value).doubleValue) + } + + private func queueDistanceMeterForSubmit() throws -> Int { + _ = try normalizedMaxQueueRangeForDisplay() + let value = Decimal(string: maxQueueRangeKm, locale: Locale(identifier: "en_US_POSIX")) ?? 0 + return NSDecimalNumber(decimal: value * Decimal(1000)).intValue + } + + private func loadQRCodeImage(from text: String) async throws -> UIImage { + if let url = URL(string: text), ["http", "https"].contains(url.scheme?.lowercased()) { + let (data, _) = try await URLSession.shared.data(from: url) + if let image = UIImage(data: data) { return image } + } + if let generated = Self.generateQRCode(from: text) { return generated } + throw APIError.networkFailed("二维码图片生成失败") + } + + private static func generateQRCode(from string: String) -> UIImage? { + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + guard let outputImage = filter.outputImage else { return nil } + let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: 8, y: 8)) + let context = CIContext() + guard let cgImage = context.createCGImage(transformed, from: transformed.extent) else { return nil } + return UIImage(cgImage: cgImage) + } + + private func saveTimingSnapshot(min: Int, sec: Int, interval: Int, countdown: Int) { + UserDefaults.standard.set(max(min * 60 + sec, 0), forKey: ScenicQueueLocalSettings.photoEstimateSecondsKey) + UserDefaults.standard.set(interval, forKey: ScenicQueueLocalSettings.broadcastIntervalSecondsKey) + UserDefaults.standard.set(countdown, forKey: ScenicQueueLocalSettings.countdownThresholdSecondsKey) + } + + private func saveShootingCallSettings(autoCallAhead: Int) { + UserDefaults.standard.set(showStartShootingButton, forKey: ScenicQueueLocalSettings.showStartShootingButtonKey) + UserDefaults.standard.set(autoCallAhead, forKey: ScenicQueueLocalSettings.autoCallAheadCountKey) + UserDefaults.standard.set(quickCallButtonEnabled, forKey: ScenicQueueLocalSettings.quickCallButtonEnabledKey) + UserDefaults.standard.set(prepareCallButtonEnabled, forKey: ScenicQueueLocalSettings.prepareCallButtonEnabledKey) + } + + private func saveCustomTextLocally() { + ScenicQueueSettingsStore.saveCustomTtsText(customTtsText, userId: currentUserId, scenicId: currentScenicId, spotId: selectedSpotId) + } + + private func voiceBroadcastsForSavePayload() -> [ScenicQueueVoiceBroadcastItem] { + Array(presetVoices + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(5) + .enumerated() + .map { index, content in ScenicQueueVoiceBroadcastItem(content: content, sortOrder: index + 1) }) + } + + private func makeSnapshot( + min: Int? = nil, + sec: Int? = nil, + first: Int? = nil, + second: Int? = nil, + interval: Int? = nil, + countdown: Int? = nil, + queueDistanceMeter: Int? = nil, + queueTakeLimit: Int? = nil, + missCallRequeueOffset: Int? = nil, + autoCallAhead: Int? = nil + ) -> ScenicQueueSettingsSnapshot { + ScenicQueueSettingsSnapshot( + shootMinute: min ?? (Int(photoEstimateMin) ?? 0), + shootSecond: sec ?? (Int(photoEstimateSec) ?? 30), + firstAheadCount: first ?? (Int(firstAhead) ?? 0), + firstSms: firstSms, + firstPhone: firstPhone, + secondAheadCount: second ?? (Int(secondAhead) ?? 0), + secondSms: secondSms, + secondPhone: secondPhone, + broadcastIntervalSec: interval ?? (Int(broadcastIntervalSec) ?? 50), + countdownThresholdSec: countdown ?? (Int(countdownThresholdSec) ?? 15), + queueDistanceMeter: queueDistanceMeter ?? ((Decimal(string: maxQueueRangeKm) ?? 0) as NSDecimalNumber).intValue * 1000, + queueTakeLimit: queueTakeLimit ?? (Int(localQueueLimit) ?? 0), + missCallRequeueOffset: missCallRequeueOffset ?? (Int(localPassDelay) ?? 1), + showStartShootingButton: showStartShootingButton, + autoCallAheadCount: autoCallAhead ?? (Int(autoCallAheadCount) ?? 0), + quickCallButtonEnabled: quickCallButtonEnabled, + prepareCallButtonEnabled: prepareCallButtonEnabled, + businessOpen: queueOpen, + businessStartTime: Self.businessTimePayload(businessStartTime), + businessEndTime: Self.businessTimePayload(businessEndTime) + ) + } + + static func coercedBroadcastInterval(_ raw: Int) -> Int { + (40...60).contains(raw) ? raw : 50 + } + + static func coercedCountdownThreshold(_ raw: Int) -> Int { + (10...30).contains(raw) ? raw : 15 + } + + static func businessTime(hour: Int, minute: Int) -> Date { + Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date() + } + + static func parseBusinessTime(_ raw: String, fallback: Date) -> Date { + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return fallback } + for format in ["HH:mm", "HH:mm:ss"] { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = format + if let date = formatter.date(from: text) { return date } + } + return fallback + } + + static func businessTimePayload(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + private static func queueDistanceDisplay(meters: Int) -> String { + let clamped = min(max(meters, 0), 9_999_990) + return String(format: "%.2f", Double(clamped) / 1000) + } +} + +private extension String { + var nilIfEmpty: String? { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + + func nonEmptyOrDefault(_ fallback: String) -> String { + let text = trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? fallback : text + } +} diff --git a/suixinkan_ios/Features/ScenicPermission/API/ScenicPermissionAPI.swift b/suixinkan_ios/Features/ScenicPermission/API/ScenicPermissionAPI.swift new file mode 100644 index 0000000..ec022d1 --- /dev/null +++ b/suixinkan_ios/Features/ScenicPermission/API/ScenicPermissionAPI.swift @@ -0,0 +1,119 @@ +// +// ScenicPermissionAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 景区权限模块服务协议,定义景区选择、权限申请和景区申请所需接口。 +@MainActor +protocol ScenicPermissionServing { + /// 获取所有可申请景区列表。 + func scenicListAll() async throws -> ScenicListAllResponse + + /// 获取省市区地区树。 + func areas() async throws -> [ScenicAreaNode] + + /// 获取当前用户的景区申请记录。 + func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse + + /// 提交新增景区入驻申请。 + func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws + + /// 提交景区申请图片占位信息。 + func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws + + /// 获取当前用户的角色权限申请记录。 + func roleApplyAll() async throws -> [RoleApplyPendingResponse] + + /// 提交角色景区权限申请。 + func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws +} + +@MainActor +/// 景区权限 API,封装景区选择和申请相关网络请求。 +final class ScenicPermissionAPI { + private let client: APIClient + + /// 初始化景区权限 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取所有可申请景区列表。 + func scenicListAll() async throws -> ScenicListAllResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic/list-all" + ) + ) + } + + /// 获取省市区地区树。 + func areas() async throws -> [ScenicAreaNode] { + try await client.send( + APIRequest( + method: .get, + path: "/api/app/config/areas" + ) + ) + } + + /// 获取当前用户的景区申请记录。 + func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic-apply/all" + ) + ) + } + + /// 提交新增景区入驻申请。 + func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/scenic-apply/submit", + body: request + ) + ) + } + + /// 提交景区申请图片占位信息。 + func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/scenic-apply/upload-placeholder", + body: ["files": items] + ) + ) + } + + /// 获取当前用户的角色权限申请记录。 + func roleApplyAll() async throws -> [RoleApplyPendingResponse] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/role-apply/all" + ) + ) + } + + /// 提交角色景区权限申请。 + func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/role-apply/submit", + body: RoleApplySubmitRequest(scenicId: scenicIds, roleId: roleId) + ) + ) + } +} + +extension ScenicPermissionAPI: ScenicPermissionServing {} diff --git a/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift b/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift new file mode 100644 index 0000000..755928d --- /dev/null +++ b/suixinkan_ios/Features/ScenicPermission/Models/ScenicPermissionModels.swift @@ -0,0 +1,336 @@ +// +// ScenicPermissionModels.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 景区申请地区节点实体,表示省市区树中的一个可选节点。 +struct ScenicAreaNode: Decodable, Equatable, Identifiable { + let id: String + let code: String + let name: String + let children: [ScenicAreaNode] + + enum CodingKeys: String, CodingKey { + case id + case code + case name + case children + } + + /// 创建地区节点,主要用于单元测试和本地构造。 + init(id: String, code: String = "", name: String, children: [ScenicAreaNode] = []) { + self.id = id + self.code = code + self.name = name + self.children = children + } + + /// 自定义解码,兼容后端 id/code 为数字或字符串。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeLossyString(forKey: .code) + let rawId = try container.decodeLossyString(forKey: .id).trimmingCharacters(in: .whitespacesAndNewlines) + id = rawId.isEmpty ? code : rawId + name = try container.decodeLossyString(forKey: .name) + children = try container.decodeIfPresent([ScenicAreaNode].self, forKey: .children) ?? [] + } +} + +/// 景区申请待审核列表响应实体,表示当前用户提交过的景区入驻申请。 +struct ScenicApplicationPendingsResponse: Decodable, Equatable { + let items: [ScenicApplicationPendingResponse] + + enum CodingKeys: String, CodingKey { + case items + case list + case data + } + + /// 创建待审核列表响应实体,主要用于测试替身。 + init(items: [ScenicApplicationPendingResponse] = []) { + self.items = items + } + + /// 自定义解码,兼容后端返回 items/list/data 三种列表字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + items = try container.decodeIfPresent([ScenicApplicationPendingResponse].self, forKey: .items) + ?? container.decodeIfPresent([ScenicApplicationPendingResponse].self, forKey: .list) + ?? container.decodeIfPresent([ScenicApplicationPendingResponse].self, forKey: .data) + ?? [] + } +} + +/// 景区申请待审核实体,表示新增景区申请的审核状态和表单快照。 +struct ScenicApplicationPendingResponse: Decodable, Equatable, Identifiable { + let id: Int + let code: String + let scenicId: Int + let scenicName: String + let scenicImages: String + let scenicProvince: String + let scenicCity: String + let coopType: Int + let remark: String? + let status: Int + let rejectReason: String? + let auditedBy: String? + let auditedAt: String? + let auditNote: String? + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case code + case scenicId = "scenic_id" + case scenicName = "scenic_name" + case scenicImages = "scenic_images" + case scenicProvince = "scenic_province" + case scenicCity = "scenic_city" + case coopType = "coop_type" + case remark + case status + case rejectReason = "reject_reason" + case auditedBy = "audited_by" + case auditedAt = "audited_at" + case auditNote = "audit_note" + case createdAt = "created_at" + } + + /// 创建景区申请待审核实体,主要用于测试和本地回填。 + init( + id: Int, + code: String = "", + scenicId: Int = 0, + scenicName: String, + scenicImages: String = "", + scenicProvince: String = "", + scenicCity: String = "", + coopType: Int = 1, + remark: String? = nil, + status: Int = 1, + rejectReason: String? = nil, + auditedBy: String? = nil, + auditedAt: String? = nil, + auditNote: String? = nil, + createdAt: String = "" + ) { + self.id = id + self.code = code + self.scenicId = scenicId + self.scenicName = scenicName + self.scenicImages = scenicImages + self.scenicProvince = scenicProvince + self.scenicCity = scenicCity + self.coopType = coopType + self.remark = remark + self.status = status + self.rejectReason = rejectReason + self.auditedBy = auditedBy + self.auditedAt = auditedAt + self.auditNote = auditNote + self.createdAt = createdAt + } + + /// 自定义解码,兼容后端数字和字符串混用。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + code = try container.decodeLossyString(forKey: .code) + scenicId = try container.decodeLossyInt(forKey: .scenicId) ?? 0 + scenicName = try container.decodeLossyString(forKey: .scenicName) + if let images = try? container.decode([String].self, forKey: .scenicImages) { + scenicImages = images.joined(separator: ",") + } else { + scenicImages = try container.decodeLossyString(forKey: .scenicImages) + } + scenicProvince = try container.decodeLossyString(forKey: .scenicProvince) + scenicCity = try container.decodeLossyString(forKey: .scenicCity) + coopType = try container.decodeLossyInt(forKey: .coopType) ?? 1 + remark = try? container.decodeIfPresent(String.self, forKey: .remark) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + rejectReason = try? container.decodeIfPresent(String.self, forKey: .rejectReason) + auditedBy = try? container.decodeIfPresent(String.self, forKey: .auditedBy) + auditedAt = try? container.decodeIfPresent(String.self, forKey: .auditedAt) + auditNote = try? container.decodeIfPresent(String.self, forKey: .auditNote) + createdAt = try container.decodeLossyString(forKey: .createdAt) + } +} + +/// 景区申请提交请求实体,表示新增景区入驻申请表单。 +struct ScenicApplicationSubmitRequest: Encodable, Equatable { + let scenicName: String? + let scenicImages: [String]? + let scenicProvince: String + let scenicCity: String + let coopType: Int + let remark: String + let scenicId: Int + + enum CodingKeys: String, CodingKey { + case scenicName = "scenic_name" + case scenicImages = "scenic_images" + case scenicProvince = "scenic_province" + case scenicCity = "scenic_city" + case coopType = "coop_type" + case remark + case scenicId = "scenic_id" + } +} + +/// 角色权限申请实体,表示一条角色/景区权限申请状态。 +struct RoleApplyPendingResponse: Decodable, Equatable, Hashable, Identifiable { + let id: Int + let code: String + let roleId: Int + let roleName: String + let scenicList: [RoleApplyScenicItem] + let status: Int + let statusLabel: String + let createdAt: String + let auditedBy: String? + let auditedAt: String? + let auditNote: String? + + enum CodingKeys: String, CodingKey { + case id + case code + case roleId = "role_id" + case roleName = "role_name" + case scenicList = "scenic_list" + case status + case statusLabel = "status_label" + case createdAt = "created_at" + case auditedBy = "audited_by" + case auditedAt = "audited_at" + case auditNote = "audit_note" + } + + /// 创建角色权限申请实体,主要用于测试和状态页回填。 + init( + id: Int, + code: String = "", + roleId: Int, + roleName: String, + scenicList: [RoleApplyScenicItem] = [], + status: Int = 1, + statusLabel: String = "审核中", + createdAt: String = "", + auditedBy: String? = nil, + auditedAt: String? = nil, + auditNote: String? = nil + ) { + self.id = id + self.code = code + self.roleId = roleId + self.roleName = roleName + self.scenicList = scenicList + self.status = status + self.statusLabel = statusLabel + self.createdAt = createdAt + self.auditedBy = auditedBy + self.auditedAt = auditedAt + self.auditNote = auditNote + } + + /// 自定义解码,兼容后端字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + code = try container.decodeLossyString(forKey: .code) + roleId = try container.decodeLossyInt(forKey: .roleId) ?? 0 + roleName = try container.decodeLossyString(forKey: .roleName) + scenicList = try container.decodeIfPresent([RoleApplyScenicItem].self, forKey: .scenicList) ?? [] + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusLabel = try container.decodeLossyString(forKey: .statusLabel) + createdAt = try container.decodeLossyString(forKey: .createdAt) + auditedBy = try? container.decodeIfPresent(String.self, forKey: .auditedBy) + auditedAt = try? container.decodeIfPresent(String.self, forKey: .auditedAt) + auditNote = try? container.decodeIfPresent(String.self, forKey: .auditNote) + } +} + +/// 角色权限申请景区实体,表示申请中勾选的一个景区。 +struct RoleApplyScenicItem: Decodable, Equatable, Hashable, Identifiable { + let id: Int + let name: String + + 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 RoleApplySubmitRequest: Encodable, Equatable { + let scenicId: [Int] + let roleId: Int + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case roleId = "role_id" + } +} + +/// 上传占位请求实体,表示景区申请已上传文件的轻量元信息。 +struct ScenicApplicationUploadPlaceholder: Encodable, Equatable { + let fileName: String + let fileType: String + let fileSize: Int64 + + enum CodingKeys: String, CodingKey { + case fileName = "file_name" + case fileType = "file_type" + case fileSize = "file_size" + } +} + +private extension KeyedDecodingContainer { + /// 宽松解码字符串,兼容后端数字、布尔值和空值。 + 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 ? "1" : "0" + } + return "" + } + + /// 宽松解码整数,兼容后端字符串和浮点数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} diff --git a/suixinkan_ios/Features/ScenicPermission/ScenicPermission.md b/suixinkan_ios/Features/ScenicPermission/ScenicPermission.md new file mode 100644 index 0000000..6facbe7 --- /dev/null +++ b/suixinkan_ios/Features/ScenicPermission/ScenicPermission.md @@ -0,0 +1,29 @@ +# 景区权限模块 + +## 模块职责 + +`Features/ScenicPermission` 承接首页 `scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 权限入口,负责景区选择、角色景区权限申请、新增景区申请和申请状态展示。 + +## 代码结构 + +- `ScenicPermissionAPI`:封装景区列表、地区树、角色权限申请和景区申请接口。 +- `ScenicSelectionViewModel`:管理景区搜索、定位距离、最近景区标记和切换景区持久化。 +- `PermissionApplyViewModel`:管理申请角色、申请景区、多选状态、已有权限禁用和提交审核。 +- `ScenicApplicationViewModel`:管理新增景区申请表单、图片上传、待审核记录回填和提交。 +- `PermissionApplyStatusViewModel`:管理权限申请状态读取和驳回记录回填入口。 + +## 业务流程 + +景区选择页读取 `AccountContext.scenicScopes`,展示当前角色可访问景区。用户切换景区后调用 `AccountContext.selectScenic(id:)`,并通过 `AccountSnapshotStore.saveCurrentSelection` 保存当前景区、门店和角色 ID。`RootView` 会继续按当前景区 ID 懒加载景点/打卡点数据。 + +定位只用于计算景区距离和标记“距离最近”。定位结果不上传、不缓存;定位失败时页面保留景区列表并显示提示。 + +权限申请页读取 `PermissionContext.rolePermissions` 生成角色选项和已有权限展示。选择角色后请求景区列表,当前角色已拥有的景区禁用且不可提交。提交成功后等待后台审核。 + +新增景区申请页先加载地区树,再读取待审核、驳回或取消的景区申请记录。审核中的记录只读;驳回记录可编辑后重新提交。提交时先使用 `OSSUploadService.uploadScenicApplicationImage` 上传本地图片,再提交最终图片 URL 和表单数据。 + +## 缓存边界 + +当前景区、当前门店、角色 ID 和景区作用域属于账号快照,可保存到 UserDefaults。定位结果、景区申请图片临时数据、OSS STS token、上传进度和表单输入不落盘。 + +`scenic_settlement` 和 `scenic_settlement_review` 是首页独立结算业务入口,已由 `Features/ScenicSettlement` 接管。ScenicPermission 模块只提供景区列表、景区申请记录和权限申请记录等可复用接口。 diff --git a/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift b/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift new file mode 100644 index 0000000..c939f43 --- /dev/null +++ b/suixinkan_ios/Features/ScenicPermission/ViewControllers/ScenicPermissionViewControllers.swift @@ -0,0 +1,216 @@ +// +// ScenicPermissionViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 景区选择页。 +final class ScenicSelectionViewController: ModuleTableViewController { + private let viewModel = ScenicSelectionViewModel() + private let searchBar = UISearchBar() + + override func viewDidLoad() { + title = "选择景区" + super.viewDidLoad() + setupSearchBar() + wireViewModel(viewModel) { } + } + + private func setupSearchBar() { + searchBar.placeholder = "搜索景区" + searchBar.delegate = self + searchBar.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 56) + tableView.tableHeaderView = searchBar + } + + override func tableRowCount() -> Int { + viewModel.filteredItems.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.filteredItems[indexPath.row] + cell.configure(title: item.name, subtitle: item.address, detail: item.distanceText) + } + + override func didSelectTableRow(at indexPath: IndexPath) { + let item = viewModel.filteredItems[indexPath.row] + viewModel.select( + scenicId: item.id, + accountContext: services.accountContext, + permissionContext: services.permissionContext, + snapshotStore: AccountSnapshotStore() + ) + navigationController?.popViewController(animated: true) + } + + override func reloadContent() async { + viewModel.reload(from: services.accountContext) + } +} + +extension ScenicSelectionViewModel: ViewModelBindable {} + +extension ScenicSelectionViewController: UISearchBarDelegate { + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + viewModel.searchQuery = searchText + } +} + +/// 权限申请页。 +final class PermissionApplyViewController: ModuleTableViewController { + private let viewModel = PermissionApplyViewModel() + + override func viewDidLoad() { + title = "权限申请" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "提交", + style: .done, + target: self, + action: #selector(submit) + ) + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? viewModel.roleOptions.count : viewModel.scenicOptions.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? "选择角色" : "选择景区" + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, + for: indexPath + ) as! TitleSubtitleTableViewCell + if indexPath.section == 0 { + let role = viewModel.roleOptions[indexPath.row] + cell.configure(title: role.name, subtitle: role.notes) + cell.accessoryType = viewModel.selectedRoleId == role.id ? .checkmark : .none + } else { + let scenic = viewModel.scenicOptions[indexPath.row] + cell.configure(title: scenic.name, subtitle: scenic.disabled ? "已有权限" : nil) + cell.accessoryType = scenic.selected ? .checkmark : .none + } + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + if indexPath.section == 0 { + viewModel.selectRole(id: viewModel.roleOptions[indexPath.row].id) + Task { await viewModel.loadScenicListIfNeeded(api: services.scenicPermissionAPI, force: true) } + } else { + viewModel.toggleScenic(id: viewModel.scenicOptions[indexPath.row].id) + } + } + + override func reloadContent() async { + viewModel.bootstrap(rolePermissions: services.permissionContext.rolePermissions) + if viewModel.selectedRoleId != nil { + await viewModel.loadScenicListIfNeeded(api: services.scenicPermissionAPI, force: true) + } + } + + @objc private func submit() { + Task { + await viewModel.submit(api: services.scenicPermissionAPI) + if viewModel.message == "提交成功,等待审核" { + services.toastCenter.show(viewModel.message ?? "提交成功") + navigationController?.popViewController(animated: true) + } else if let message = viewModel.message { + services.toastCenter.show(message) + } + } + } +} + +extension PermissionApplyViewModel: ViewModelBindable {} + +/// 权限申请状态页。 +final class PermissionApplyStatusViewController: ModuleTableViewController { + private let viewModel = PermissionApplyStatusViewModel() + + override func viewDidLoad() { + title = "申请状态" + super.viewDidLoad() + viewModel.onChange = { [weak self] in self?.reloadTable() } + } + + override func tableRowCount() -> Int { + viewModel.pending == nil ? 0 : 4 + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + guard let pending = viewModel.pending else { return } + switch indexPath.row { + case 0: cell.configure(title: "申请编号", subtitle: pending.code) + case 1: cell.configure(title: "角色", subtitle: pending.roleName) + case 2: cell.configure(title: "状态", subtitle: pending.statusLabel) + default: cell.configure(title: "申请时间", subtitle: pending.createdAt) + } + } + + override func reloadContent() async { + await viewModel.load(api: services.scenicPermissionAPI, applyCode: nil) + } +} + +/// 景区申请页。 +final class ScenicApplicationViewController: ModuleTableViewController { + private let viewModel = ScenicApplicationViewModel() + private let nameField = UITextField() + + override func viewDidLoad() { + title = "景区申请" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "提交", + style: .done, + target: self, + action: #selector(submit) + ) + super.viewDidLoad() + nameField.placeholder = "景区名称" + nameField.borderStyle = .roundedRect + nameField.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52) + tableView.tableHeaderView = nameField + wireViewModel(viewModel) { [weak self] in + if self?.nameField.text?.isEmpty != false { + self?.nameField.text = self?.viewModel.scenicName + } + } + } + + override func tableRowCount() -> Int { 3 } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + switch indexPath.row { + case 0: cell.configure(title: "省份", subtitle: viewModel.selectedProvince) + case 1: cell.configure(title: "城市", subtitle: viewModel.selectedCity) + default: cell.configure(title: "合作类型", subtitle: viewModel.coopType == 1 ? "自营" : "合作") + } + } + + override func reloadContent() async { + await viewModel.loadInitial(api: services.scenicPermissionAPI) + } + + @objc private func submit() { + viewModel.scenicName = nameField.text ?? "" + Task { + await viewModel.submit(api: services.scenicPermissionAPI, uploader: services.ossUploadService) + if let message = viewModel.message { + services.toastCenter.show(message) + } + } + } +} + +extension ScenicApplicationViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/ScenicPermission/ViewModels/PermissionApplyViewModel.swift b/suixinkan_ios/Features/ScenicPermission/ViewModels/PermissionApplyViewModel.swift new file mode 100644 index 0000000..b253457 --- /dev/null +++ b/suixinkan_ios/Features/ScenicPermission/ViewModels/PermissionApplyViewModel.swift @@ -0,0 +1,211 @@ +// +// PermissionApplyViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 权限申请角色选项实体,表示可申请的一个业务角色。 +struct PermissionRoleOption: Equatable, Identifiable { + let id: Int + let name: String + let notes: String +} + +/// 权限申请景区选项实体,表示可申请或已拥有权限的一个景区。 +struct PermissionScenicOption: Equatable, Identifiable { + let id: Int + let name: String + var selected: Bool + var disabled: Bool +} + +/// 已有角色权限展示实体,表示一个角色及其已拥有景区。 +struct ExistingRolePermissionInfo: Equatable, Identifiable { + let id: Int + let name: String + let scenics: [ScenicInfo] +} + +@MainActor +/// 权限申请 ViewModel,负责角色/景区选择和提交审核。 +final class PermissionApplyViewModel { + var onChange: (() -> Void)? + var roleOptions: [PermissionRoleOption] = [] { didSet { onChange?() } } + var selectedRoleId: Int? { didSet { onChange?() } } + var scenicOptions: [PermissionScenicOption] = [] { didSet { onChange?() } } + var loadingScenics = false { didSet { onChange?() } } + var scenicLoadFailed = false { didSet { onChange?() } } + var scenicLoadFailureReason: String? { didSet { onChange?() } } + var submitting = false { didSet { onChange?() } } + var message: String? { didSet { onChange?() } } + + private let initialPending: RoleApplyPendingResponse? + private var rolePermissions: [RolePermissionResponse] = [] + private var selectedIds: Set = [] + + /// 初始化权限申请 ViewModel,可传入驳回申请用于编辑回填。 + init(initialPending: RoleApplyPendingResponse? = nil) { + self.initialPending = initialPending + } + + /// 返回是否允许提交审核。 + var canSubmit: Bool { + !submitting && selectedRoleId != nil && !selectedIds.isEmpty + } + + /// 返回已选择景区数量。 + var selectedCount: Int { + selectedIds.count + } + + /// 返回已选择景区 ID 集合,供测试和提交校验使用。 + var selectedScenicIds: Set { + selectedIds + } + + /// 返回景区选择摘要文案。 + var selectedScenicSummary: String { + let names = scenicOptions + .filter { !$0.disabled && selectedIds.contains($0.id) } + .map(\.name) + if names.isEmpty { return "请选择景区" } + if names.count == 1 { return names[0] } + return "已选择 \(names.count) 个景区" + } + + /// 返回当前选择角色的说明。 + var selectedRoleNotes: String? { + guard let selectedRoleId else { return nil } + return roleOptions.first(where: { $0.id == selectedRoleId })?.notes + } + + /// 返回当前选择角色名称。 + var selectedRoleName: String? { + guard let selectedRoleId else { return nil } + return roleOptions.first(where: { $0.id == selectedRoleId })?.name + } + + /// 返回已有角色权限展示列表。 + var existingRoleInfos: [ExistingRolePermissionInfo] { + rolePermissions.map { + ExistingRolePermissionInfo(id: $0.role.id, name: $0.role.name, scenics: $0.scenic) + } + } + + /// 从权限上下文启动表单数据。 + func bootstrap(rolePermissions: [RolePermissionResponse]) { + self.rolePermissions = rolePermissions + roleOptions = uniqueRoles(from: rolePermissions) + if let pending = initialPending { + selectedRoleId = pending.roleId + selectedIds = Set(pending.scenicList.map(\.id)) + } + } + + /// 选择角色,并清空当前景区选择等待重新加载。 + func selectRole(id: Int) { + selectedRoleId = id + selectedIds.removeAll() + scenicOptions = [] + scenicLoadFailed = false + scenicLoadFailureReason = nil + } + + /// 按当前角色加载可申请景区列表。 + func loadScenicListIfNeeded(api: any ScenicPermissionServing, force: Bool = false) async { + guard selectedRoleId != nil else { return } + if !force && !scenicOptions.isEmpty { return } + loadingScenics = true + scenicLoadFailed = false + scenicLoadFailureReason = nil + defer { loadingScenics = false } + do { + let list = try await api.scenicListAll().list + let disabledIds = Set(currentRolePermission()?.scenic.map(\.id) ?? []) + scenicOptions = list.map { scenic in + let selected = disabledIds.contains(scenic.id) || selectedIds.contains(scenic.id) + return PermissionScenicOption(id: scenic.id, name: scenic.name, selected: selected, disabled: disabledIds.contains(scenic.id)) + } + selectedIds = Set(scenicOptions.filter { $0.selected && !$0.disabled }.map(\.id)) + } catch { + scenicOptions = [] + selectedIds.removeAll() + scenicLoadFailed = true + scenicLoadFailureReason = error.localizedDescription + message = "景区列表加载失败,请重试" + } + } + + /// 切换一个景区的选择状态,已有权限的景区不可切换。 + func toggleScenic(id: Int) { + guard let index = scenicOptions.firstIndex(where: { $0.id == id }), !scenicOptions[index].disabled else { return } + scenicOptions[index].selected.toggle() + if scenicOptions[index].selected { + selectedIds.insert(id) + } else { + selectedIds.remove(id) + } + } + + /// 提交角色权限申请。 + func submit(api: any ScenicPermissionServing) async { + guard let roleId = selectedRoleId, !selectedIds.isEmpty, !submitting else { return } + submitting = true + defer { submitting = false } + do { + try await api.roleApplySubmit(roleId: roleId, scenicIds: Array(selectedIds).sorted()) + message = "提交成功,等待审核" + } catch { + message = error.localizedDescription + } + } + + /// 生成去重后的角色选项。 + private func uniqueRoles(from list: [RolePermissionResponse]) -> [PermissionRoleOption] { + var seen = Set() + return list.compactMap { item in + guard seen.insert(item.role.id).inserted else { return nil } + return PermissionRoleOption(id: item.role.id, name: item.role.name, notes: item.role.notes ?? "") + } + } + + /// 返回当前选择角色对应的权限实体。 + private func currentRolePermission() -> RolePermissionResponse? { + guard let selectedRoleId else { return nil } + return rolePermissions.first { $0.role.id == selectedRoleId } + } +} + +@MainActor +/// 权限申请状态 ViewModel,负责读取审核中的或指定编号的权限申请。 +final class PermissionApplyStatusViewModel { + var onChange: (() -> Void)? + var loading = false { didSet { onChange?() } } + var pending: RoleApplyPendingResponse? { didSet { onChange?() } } + var loadFailed = false { didSet { onChange?() } } + var loadFailureReason: String? { didSet { onChange?() } } + + /// 加载权限申请状态;有申请编号时优先匹配编号,否则展示审核中或驳回记录。 + func load(api: any ScenicPermissionServing, applyCode: String?) async { + loading = true + loadFailed = false + loadFailureReason = nil + defer { loading = false } + do { + let all = try await api.roleApplyAll() + let normalizedCode = applyCode?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !normalizedCode.isEmpty { + pending = all.first { $0.code == normalizedCode } + } else { + pending = all.first { $0.status == 1 || $0.status == 3 } + } + } catch { + pending = nil + loadFailed = true + loadFailureReason = error.localizedDescription + } + } +} diff --git a/suixinkan_ios/Features/ScenicPermission/ViewModels/ScenicApplicationViewModel.swift b/suixinkan_ios/Features/ScenicPermission/ViewModels/ScenicApplicationViewModel.swift new file mode 100644 index 0000000..c5fc2a8 --- /dev/null +++ b/suixinkan_ios/Features/ScenicPermission/ViewModels/ScenicApplicationViewModel.swift @@ -0,0 +1,316 @@ +// +// ScenicApplicationViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 景区申请本地图片草稿实体,表示尚未或正在上传的图片。 +struct ScenicApplicationImageDraft: Equatable, Identifiable { + let id: UUID + let data: Data + let fileName: String + var uploadedURL: String? + var uploading: Bool + var progress: Int + + /// 创建景区申请图片草稿。 + init(id: UUID = UUID(), data: Data, fileName: String, uploadedURL: String? = nil, uploading: Bool = false, progress: Int = 0) { + self.id = id + self.data = data + self.fileName = fileName + self.uploadedURL = uploadedURL + self.uploading = uploading + self.progress = progress + } +} + +@MainActor +/// 景区申请 ViewModel,负责新增景区申请表单、图片上传和待审核记录回填。 +final class ScenicApplicationViewModel { + var onChange: (() -> Void)? + var scenicName = "" { didSet { onChange?() } } + var imageURLs = "" { didSet { onChange?() } } + var localImages: [ScenicApplicationImageDraft] = [] { didSet { onChange?() } } + var selectedProvince = "" { didSet { onChange?() } } + var selectedCity = "" { didSet { onChange?() } } + var coopType = 1 { didSet { onChange?() } } + var remark = "" { didSet { onChange?() } } + var agreed = false { didSet { onChange?() } } + var submitting = false { didSet { onChange?() } } + var message: String? { didSet { onChange?() } } + var provinces: [ScenicAreaNode] = [] { didSet { onChange?() } } + var cities: [ScenicAreaNode] = [] { didSet { onChange?() } } + var pending: ScenicApplicationPendingResponse? { didSet { onChange?() } } + var loading = false { didSet { onChange?() } } + var loadFailed = false { didSet { onChange?() } } + var loadFailureReason: String? { didSet { onChange?() } } + var pendingLoadFailed = false { didSet { onChange?() } } + var pendingLoadFailureReason: String? { didSet { onChange?() } } + + /// 返回当前申请是否只读;审核中的申请不可再次编辑提交。 + var isReadOnly: Bool { + pending?.status == 1 + } + + /// 返回当前表单是否允许提交。 + var canSubmit: Bool { + !isReadOnly && !submitting && validationMessage == nil + } + + /// 返回提交按钮是否需要锁定。 + var isSubmitLocked: Bool { + isReadOnly || submitting + } + + /// 返回远程图片和本地图片总数。 + var uploadPlaceholderCount: Int { + remoteImageURLs.count + localImages.count + } + + /// 返回按行或逗号分割后的远程图片 URL。 + var remoteImageURLs: [String] { + imageURLs + .split(whereSeparator: \.isNewline) + .flatMap { $0.split(separator: ",") } + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + /// 加载地区树和待审核景区申请。 + func loadInitial(api: any ScenicPermissionServing) async { + loading = true + loadFailed = false + loadFailureReason = nil + defer { loading = false } + do { + provinces = try await api.areas() + } catch { + let reason = "地区数据加载失败:\(error.localizedDescription)" + message = reason + loadFailed = true + loadFailureReason = reason + provinces = [] + cities = [] + clearPendingApplication() + return + } + await reloadPending(api: api) + } + + /// 重新加载待审核、审核中或驳回的景区申请。 + func reloadPending(api: any ScenicPermissionServing) async { + pendingLoadFailed = false + pendingLoadFailureReason = nil + do { + let pendingList = try await api.scenicApplicationPendingAll().items + if let first = pendingList.first(where: { $0.status == 1 || $0.status == 3 || $0.status == 9 }) { + applyPending(first) + } else { + clearPendingApplication() + } + } catch { + clearPendingApplication() + pendingLoadFailed = true + pendingLoadFailureReason = error.localizedDescription + } + } + + /// 省份变更后刷新城市列表,并在旧城市不匹配时清空城市。 + func onProvinceChange() { + let oldCity = selectedCity + cities = provinces.first(where: { $0.name == selectedProvince })?.children ?? [] + if !cities.contains(where: { $0.name == oldCity }) { + selectedCity = "" + } + } + + /// 添加一张本地景区图片。 + func addLocalImage(data: Data, fileName: String) { + guard uploadPlaceholderCount < 20 else { + message = "最多上传20张景区图片" + return + } + localImages.append(ScenicApplicationImageDraft(data: data, fileName: fileName)) + } + + /// 删除一张远程图片。 + func removeRemoteImage(_ url: String) { + imageURLs = remoteImageURLs.filter { $0 != url }.joined(separator: "\n") + } + + /// 删除一张本地图片。 + func removeLocalImage(id: UUID) { + localImages.removeAll { $0.id == id } + } + + /// 提交景区申请;本地图片会先上传 OSS,全部成功后再提交业务表单。 + func submit(api: any ScenicPermissionServing, uploader: any OSSUploadServing) async { + if let validationMessage { + message = validationMessage + return + } + guard !isSubmitLocked else { return } + submitting = true + defer { submitting = false } + do { + let uploaded = try await uploadLocalImagesIfNeeded(uploader: uploader) + let urls = remoteImageURLs + uploaded + if !urls.isEmpty { + let remotePlaceholders = remoteImageURLs.map { makeUploadPlaceholder(from: $0, fileSize: 0) } + let localPlaceholders = localImages.compactMap { item -> ScenicApplicationUploadPlaceholder? in + guard let uploadedURL = item.uploadedURL else { return nil } + return makeUploadPlaceholder(from: uploadedURL, fileName: item.fileName, fileSize: Int64(item.data.count)) + } + try await api.scenicApplicationUploadPlaceholder(remotePlaceholders + localPlaceholders) + } + try await api.scenicSubmit( + ScenicApplicationSubmitRequest( + scenicName: trimmedNilIfEmpty(scenicName), + scenicImages: urls.isEmpty ? nil : urls, + scenicProvince: selectedProvince, + scenicCity: selectedCity, + coopType: coopType, + remark: remark, + scenicId: pending?.scenicId ?? 0 + ) + ) + await reloadPending(api: api) + message = "提交成功" + } catch { + message = error.localizedDescription + } + } + + /// 返回当前表单校验错误文案。 + var validationMessage: String? { + if scenicName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "请输入景区名称" + } + if uploadPlaceholderCount == 0 { + return "请至少上传一张景区图片" + } + if selectedProvince.isEmpty { + return "请选择省份" + } + if selectedCity.isEmpty { + return "请选择城市" + } + if !agreed { + return "请先阅读并同意用户须知与隐私政策" + } + return nil + } + + /// 上传尚未上传的本地图片,并返回最终 URL 列表。 + private func uploadLocalImagesIfNeeded(uploader: any OSSUploadServing) async throws -> [String] { + var urls: [String] = [] + for index in localImages.indices { + if let uploadedURL = localImages[index].uploadedURL { + urls.append(uploadedURL) + continue + } + localImages[index].uploading = true + localImages[index].progress = max(localImages[index].progress, 1) + do { + let uploadedURL = try await uploader.uploadScenicApplicationImage( + data: localImages[index].data, + fileName: localImages[index].fileName, + scenicId: pending?.scenicId ?? 0 + ) { [weak self] progress in + Task { @MainActor in + guard let self, self.localImages.indices.contains(index) else { return } + self.localImages[index].progress = progress + } + } + localImages[index].uploadedURL = uploadedURL + localImages[index].uploading = false + localImages[index].progress = 100 + urls.append(uploadedURL) + } catch { + localImages[index].uploading = false + localImages[index].progress = 0 + throw error + } + } + return urls + } + + /// 使用待审核记录回填表单。 + private func applyPending(_ item: ScenicApplicationPendingResponse) { + pending = item + scenicName = item.scenicName + imageURLs = item.scenicImages + .split(separator: ",") + .map(String.init) + .joined(separator: "\n") + selectedProvince = item.scenicProvince + selectedCity = item.scenicCity + coopType = item.coopType + remark = item.remark ?? "" + agreed = true + localImages = [] + onProvinceChange() + } + + /// 清空待审核记录和表单内容。 + private func clearPendingApplication() { + pending = nil + scenicName = "" + imageURLs = "" + localImages = [] + selectedProvince = "" + selectedCity = "" + cities = [] + coopType = 1 + remark = "" + agreed = false + } + + /// 创建上传占位请求实体。 + private func makeUploadPlaceholder(from url: String, fileName: String? = nil, fileSize: Int64) -> ScenicApplicationUploadPlaceholder { + ScenicApplicationUploadPlaceholder( + fileName: fileName ?? ScenicUploadPlaceholderPolicy.fileName(from: url, fallbackPrefix: "scenic_upload"), + fileType: ScenicUploadPlaceholderPolicy.fileType(from: url), + fileSize: fileSize + ) + } + + /// 返回去空白后的非空字符串,空值返回 nil。 + private func trimmedNilIfEmpty(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +/// 景区申请上传占位策略,负责从 URL 或文件名提取占位元信息。 +enum ScenicUploadPlaceholderPolicy { + /// 从原始 URL 或路径中提取文件名,缺失时使用稳定前缀兜底。 + static func fileName(from rawValue: String, fallbackPrefix: String, uuid: UUID = UUID()) -> String { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let stripped = trimmed + .split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first + .map(String.init)? + .split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first + .map(String.init) ?? trimmed + let urlPathName = URL(string: trimmed)?.lastPathComponent + let pathName = (stripped as NSString).lastPathComponent + let decodedName = (urlPathName?.isEmpty == false ? urlPathName : pathName)?.removingPercentEncoding + let fileName = decodedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !fileName.isEmpty, fileName != "/" else { + return "\(fallbackPrefix)_\(uuid.uuidString.prefix(8))" + } + return fileName + } + + /// 根据文件扩展名推断上传占位文件类型。 + static func fileType(from rawValue: String) -> String { + let fileName = fileName(from: rawValue, fallbackPrefix: "upload") + let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() + if ["mp4", "mov", "mkv", "avi"].contains(ext) { return "video" } + if ["pdf", "doc", "docx", "xls", "xlsx"].contains(ext) { return "file" } + return "image" + } +} diff --git a/suixinkan_ios/Features/ScenicPermission/ViewModels/ScenicSelectionLocationProvider.swift b/suixinkan_ios/Features/ScenicPermission/ViewModels/ScenicSelectionLocationProvider.swift new file mode 100644 index 0000000..6406403 --- /dev/null +++ b/suixinkan_ios/Features/ScenicPermission/ViewModels/ScenicSelectionLocationProvider.swift @@ -0,0 +1,62 @@ +// +// ScenicSelectionLocationProvider.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import CoreLocation +import Foundation + +/// 景区选择定位服务,负责向系统请求一次当前位置。 +final class ScenicSelectionLocationProvider: NSObject, CLLocationManagerDelegate { + var onLocation: ((CLLocation) -> Void)? + var onFailure: ((String) -> Void)? + + private let manager = CLLocationManager() + + /// 初始化定位服务并配置精度。 + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + /// 请求一次当前位置;未授权时先触发系统授权弹窗。 + func request() { + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + case .authorizedAlways, .authorizedWhenInUse: + manager.requestLocation() + case .denied, .restricted: + onFailure?("定位权限未开启,请在系统设置中允许访问位置") + @unknown default: + onFailure?("定位状态不可用") + } + } + + /// 监听授权状态变化,并在授权后继续请求定位。 + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + switch manager.authorizationStatus { + case .authorizedAlways, .authorizedWhenInUse: + manager.requestLocation() + case .denied, .restricted: + onFailure?("定位权限未开启,请在系统设置中允许访问位置") + default: + break + } + } + + /// 接收系统返回的位置。 + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + if let location = locations.last { + onLocation?(location) + } + } + + /// 接收系统定位错误。 + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + onFailure?("定位失败:\(error.localizedDescription)") + } +} diff --git a/suixinkan_ios/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift b/suixinkan_ios/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift new file mode 100644 index 0000000..f1584ee --- /dev/null +++ b/suixinkan_ios/Features/ScenicPermission/ViewModels/ScenicSelectionViewModel.swift @@ -0,0 +1,149 @@ +// +// ScenicSelectionViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import CoreLocation +import Foundation + +/// 景区选择展示实体,表示列表中的一个可切换景区。 +struct ScenicSelectionItem: Equatable, Identifiable { + let id: Int + let name: String + let status: Int? + let address: String + let latitude: Double? + let longitude: Double? + let coverURLString: String? + var isClosest: Bool + var distanceMeters: CLLocationDistance? + + /// 从业务作用域创建景区选择展示实体。 + init(scope: BusinessScope) { + id = scope.id + name = scope.name + status = scope.status + let trimmedAddress = scope.address?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + address = trimmedAddress.isEmpty ? "--" : trimmedAddress + latitude = scope.latitude + longitude = scope.longitude + coverURLString = scope.coverURLString + isClosest = false + distanceMeters = nil + } + + /// 返回景区距离文案,无定位结果时显示占位。 + var distanceText: String { + guard let distanceMeters else { return "--" } + if distanceMeters < 1_000 { + return "\(Int(distanceMeters.rounded()))m" + } + return String(format: "%.2fkm", distanceMeters / 1_000) + } + + /// 返回景区是否处于旧工程定义的暂未营业状态。 + var isClosed: Bool { + status == 2 + } +} + +@MainActor +/// 景区选择 ViewModel,负责搜索、定位距离计算和切换景区持久化。 +final class ScenicSelectionViewModel { + var onChange: (() -> Void)? + var searchQuery = "" { didSet { onChange?() } } + var currentLocationText = "定位后获取最近景区" { didSet { onChange?() } } + var locationWarning: String? { didSet { onChange?() } } + private(set) var items: [ScenicSelectionItem] = [] { didSet { onChange?() } } + + /// 返回按搜索关键词过滤后的景区列表。 + var filteredItems: [ScenicSelectionItem] { + let keyword = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !keyword.isEmpty else { return items } + return items.filter { item in + item.name.localizedCaseInsensitiveContains(keyword) + || item.address.localizedCaseInsensitiveContains(keyword) + } + } + + /// 从账号上下文重新加载可选景区,并优先把当前景区放在第一位。 + func reload(from accountContext: AccountContext) { + let currentScenicId = accountContext.currentScenic?.id + items = Self.makeItems(scopes: accountContext.scenicScopes, currentScenicId: currentScenicId) + currentLocationText = items.first?.address ?? "定位后获取最近景区" + locationWarning = nil + } + + /// 切换景区并把当前选择保存到账号快照。 + func select( + scenicId: Int, + accountContext: AccountContext, + permissionContext: PermissionContext, + snapshotStore: AccountSnapshotStore + ) { + accountContext.selectScenic(id: scenicId) + snapshotStore.saveCurrentSelection( + accountContext: accountContext, + currentRoleId: permissionContext.currentRole?.id + ) + } + + /// 定位成功后计算距离,并标记距离最近景区。 + func applyCurrentLocation(latitude: Double, longitude: Double) { + locationWarning = nil + let current = CLLocation(latitude: latitude, longitude: longitude) + for index in items.indices { + guard let scenicLatitude = items[index].latitude, + let scenicLongitude = items[index].longitude, + scenicLatitude != 0 || scenicLongitude != 0 + else { + items[index].distanceMeters = nil + items[index].isClosest = false + continue + } + let scenicLocation = CLLocation(latitude: scenicLatitude, longitude: scenicLongitude) + items[index].distanceMeters = scenicLocation.distance(from: current) + items[index].isClosest = false + } + if let closestIndex = items.indices + .filter({ items[$0].distanceMeters != nil }) + .min(by: { (items[$0].distanceMeters ?? .greatestFiniteMagnitude) < (items[$1].distanceMeters ?? .greatestFiniteMagnitude) }) { + items[closestIndex].isClosest = true + currentLocationText = items[closestIndex].address + } else if let first = items.first { + items[0].isClosest = true + currentLocationText = first.address + } + } + + /// 定位失败时保留原列表并暴露提示文案。 + func applyLocationFailure(_ message: String) { + locationWarning = message + currentLocationText = items.first?.address ?? "定位后获取最近景区" + if !items.isEmpty, !items.contains(where: \.isClosest) { + items[0].isClosest = true + } + } + + /// 从景区作用域生成展示列表,并把当前景区移动到首位。 + static func makeItems(scopes: [BusinessScope], currentScenicId: Int?) -> [ScenicSelectionItem] { + var seen = Set() + let uniqueItems = scopes.compactMap { scope -> ScenicSelectionItem? in + guard scope.kind == .scenic, seen.insert(scope.id).inserted else { return nil } + return ScenicSelectionItem(scope: scope) + } + var result: [ScenicSelectionItem] + if let currentScenicId, + let current = uniqueItems.first(where: { $0.id == currentScenicId }) { + result = [current] + uniqueItems.filter { $0.id != currentScenicId } + } else { + result = uniqueItems + } + if !result.isEmpty { + result[0].isClosest = true + } + return result + } +} diff --git a/suixinkan_ios/Features/ScenicSettlement/API/ScenicSettlementAPI.swift b/suixinkan_ios/Features/ScenicSettlement/API/ScenicSettlementAPI.swift new file mode 100644 index 0000000..50db101 --- /dev/null +++ b/suixinkan_ios/Features/ScenicSettlement/API/ScenicSettlementAPI.swift @@ -0,0 +1,39 @@ +// +// ScenicSettlementAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 景区结算服务协议,定义结算申请提交能力。 +@MainActor +protocol ScenicSettlementServing { + /// 提交单个景区的结算申请。 + func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws +} + +@MainActor +/// 景区结算 API,封装结算申请提交网络请求。 +final class ScenicSettlementAPI { + private let client: APIClient + + /// 初始化景区结算 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 提交单个景区的结算申请。 + func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/scenic-settlement/submit", + body: request + ) + ) + } +} + +extension ScenicSettlementAPI: ScenicSettlementServing {} diff --git a/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift b/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift new file mode 100644 index 0000000..eb0a490 --- /dev/null +++ b/suixinkan_ios/Features/ScenicSettlement/Models/ScenicSettlementModels.swift @@ -0,0 +1,28 @@ +// +// ScenicSettlementModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 景区结算可选项,表示一个可申请结算的景区。 +struct ScenicSettlementOption: Equatable, Identifiable { + let id: Int + let name: String + var selected: Bool +} + +/// 景区结算提交请求实体。 +struct ScenicSettlementSubmitRequest: Encodable, Equatable { + let scenicId: Int + let applyAmount: String + let applyRemark: String + + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case applyAmount = "apply_amount" + case applyRemark = "apply_remark" + } +} diff --git a/suixinkan_ios/Features/ScenicSettlement/ScenicSettlement.md b/suixinkan_ios/Features/ScenicSettlement/ScenicSettlement.md new file mode 100644 index 0000000..8b88355 --- /dev/null +++ b/suixinkan_ios/Features/ScenicSettlement/ScenicSettlement.md @@ -0,0 +1,23 @@ +# 景区结算模块 + +## 模块职责 + +`Features/ScenicSettlement` 承接首页 `scenic_settlement` 和 `scenic_settlement_review` 权限入口,负责景区结算申请提交和结算相关审核记录展示。 + +## 结算申请 + +`ScenicSettlementViewModel` 从账号上下文读取已有景区,并通过 `ScenicPermissionAPI.scenicListAll` 加载全部可申请景区,排除已开通景区后供用户多选。 + +提交时校验金额必填、正数且最多两位小数,再按景区 ID 升序逐条调用 `/api/yf-handset-app/photog/scenic-settlement/submit`。全部成功后清空已选景区、金额和备注;任一请求失败时保留当前表单并提示错误。 + +## 结算审核 + +`ScenicSettlementReviewViewModel` 复用景区申请记录和权限申请记录接口: +- `scenicApplicationPendingAll` +- `roleApplyAll` + +两个通道独立容错。单通道失败时展示另一通道数据并提示失败;双通道失败时展示整页失败和重新加载入口。 + +## 边界 + +旧 Android 工程中的景区结算仍是本地 mock 数据。本模块以旧 iOS 已接入的真实结算提交接口和审核记录聚合方式为迁移依据,不新增后端未体现的详情审批操作。 diff --git a/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift b/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift new file mode 100644 index 0000000..a5b2930 --- /dev/null +++ b/suixinkan_ios/Features/ScenicSettlement/ViewControllers/ScenicSettlementViewControllers.swift @@ -0,0 +1,130 @@ +// +// ScenicSettlementViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 景区结算申请页。 +final class ScenicSettlementViewController: ModuleTableViewController { + private let viewModel = ScenicSettlementViewModel() + private let amountField = UITextField() + private let remarkField = UITextField() + + override func viewDidLoad() { + title = "景区结算" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "提交", + style: .done, + target: self, + action: #selector(submit) + ) + super.viewDidLoad() + setupHeader() + wireViewModel(viewModel) { } + } + + private func setupHeader() { + amountField.placeholder = "结算金额" + amountField.borderStyle = .roundedRect + amountField.keyboardType = .decimalPad + remarkField.placeholder = "备注" + remarkField.borderStyle = .roundedRect + let stack = UIStackView(arrangedSubviews: [amountField, remarkField]) + stack.axis = .vertical + stack.spacing = 8 + stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 96) + stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) + stack.isLayoutMarginsRelativeArrangement = true + tableView.tableHeaderView = stack + } + + override func tableRowCount() -> Int { + viewModel.options.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let option = viewModel.options[indexPath.row] + cell.configure(title: option.name, subtitle: option.selected ? "已选择" : nil) + cell.accessoryType = option.selected ? .checkmark : .none + } + + override func didSelectTableRow(at indexPath: IndexPath) { + viewModel.toggleScenic(id: viewModel.options[indexPath.row].id) + } + + override func reloadContent() async { + await viewModel.load( + api: services.scenicPermissionAPI, + existingScenics: services.accountContext.scenicScopes + ) + } + + @objc private func submit() { + viewModel.amountText = amountField.text ?? "" + viewModel.remarkText = remarkField.text ?? "" + Task { + let success = await viewModel.submit(api: services.scenicSettlementAPI) + if success { + services.toastCenter.show("提交成功") + navigationController?.popViewController(animated: true) + } else if let message = viewModel.message { + services.toastCenter.show(message) + } + } + } +} + +extension ScenicSettlementViewModel: ViewModelBindable {} + +/// 景区结算审核页。 +final class ScenicSettlementReviewViewController: ModuleTableViewController { + private let viewModel = ScenicSettlementReviewViewModel() + + override func viewDidLoad() { + title = "结算审核" + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func numberOfSections(in tableView: UITableView) -> Int { 2 } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + section == 0 ? viewModel.scenicApplications.count : viewModel.roleApplications.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 0 ? "景区申请" : "权限申请" + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: TitleSubtitleTableViewCell.reuseIdentifier, + for: indexPath + ) as! TitleSubtitleTableViewCell + if indexPath.section == 0 { + let item = viewModel.scenicApplications[indexPath.row] + cell.configure( + title: item.scenicName, + subtitle: viewModel.statusText(item.status), + detail: item.createdAt + ) + } else { + let item = viewModel.roleApplications[indexPath.row] + cell.configure( + title: item.roleName, + subtitle: viewModel.statusText(item.status, fallback: item.statusLabel), + detail: item.createdAt + ) + } + return cell + } + + override func reloadContent() async { + await viewModel.load(api: services.scenicPermissionAPI) + } +} + +extension ScenicSettlementReviewViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift b/suixinkan_ios/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift new file mode 100644 index 0000000..f346dce --- /dev/null +++ b/suixinkan_ios/Features/ScenicSettlement/ViewModels/ScenicSettlementViewModels.swift @@ -0,0 +1,263 @@ +// +// ScenicSettlementViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation +import UIKit + +@MainActor +/// 景区结算申请 ViewModel,管理可申请景区、多选、金额备注和提交。 +final class ScenicSettlementViewModel { + var onChange: (() -> Void)? + var existingScenics: [BusinessScope] = [] { didSet { onChange?() } } + var options: [ScenicSettlementOption] = [] { didSet { onChange?() } } + var amountText = "" { didSet { onChange?() } } + var remarkText = "" { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isSubmitting = false { didSet { onChange?() } } + var loadFailed = false { didSet { onChange?() } } + var loadFailureReason: String? { didSet { onChange?() } } + var message: String? { didSet { onChange?() } } + + private var selectedIds = Set() + + /// 已选择景区数量。 + var selectedCount: Int { + selectedIds.count + } + + /// 当前选择的景区 ID,供测试和提交使用。 + var selectedScenicIds: Set { + selectedIds + } + + /// 金额校验状态。 + var amountValidation: ScenicSettlementAmountValidation { + let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return .empty } + guard trimmed.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil else { + return .invalidFormat + } + guard let decimal = Decimal(string: trimmed), decimal > 0 else { + return .notPositive + } + return .valid + } + + /// 是否允许提交结算申请。 + var canSubmit: Bool { + !isSubmitting && !selectedIds.isEmpty && amountValidation == .valid + } + + /// 加载已开通景区和可申请结算景区。 + func load(api: any ScenicPermissionServing, existingScenics: [BusinessScope]) async { + isLoading = true + loadFailed = false + loadFailureReason = nil + self.existingScenics = existingScenics + defer { isLoading = false } + + do { + let all = try await api.scenicListAll().list + let existingIds = Set(existingScenics.map(\.id)) + options = all + .filter { !existingIds.contains($0.id) } + .map { ScenicSettlementOption(id: $0.id, name: $0.name, selected: selectedIds.contains($0.id)) } + } catch { + options = [] + selectedIds.removeAll() + loadFailed = true + loadFailureReason = error.localizedDescription + message = "可申请景区加载失败,请重试" + } + } + + /// 切换一个景区的结算申请选择状态。 + func toggleScenic(id: Int) { + guard let index = options.firstIndex(where: { $0.id == id }) else { return } + options[index].selected.toggle() + if options[index].selected { + selectedIds.insert(id) + } else { + selectedIds.remove(id) + } + } + + /// 提交结算申请,多景区按 ID 升序逐条提交。 + func submit(api: any ScenicSettlementServing) async -> Bool { + guard !isSubmitting else { return false } + guard canSubmit else { + message = validationMessage + return false + } + + isSubmitting = true + defer { isSubmitting = false } + + let amount = normalizedAmount + let remark = remarkText.trimmingCharacters(in: .whitespacesAndNewlines) + + do { + for scenicId in selectedIds.sorted() { + try await api.scenicSettlementSubmit( + ScenicSettlementSubmitRequest( + scenicId: scenicId, + applyAmount: amount, + applyRemark: remark + ) + ) + } + selectedIds.removeAll() + options = options.map { option in + ScenicSettlementOption(id: option.id, name: option.name, selected: false) + } + amountText = "" + remarkText = "" + message = "提交成功,等待审核" + return true + } catch { + message = error.localizedDescription + return false + } + } + + private var normalizedAmount: String { + let decimal = Decimal(string: amountText.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + return String(format: "%.2f", NSDecimalNumber(decimal: decimal).doubleValue) + } + + private var validationMessage: String { + if selectedIds.isEmpty { return "请先选择景区" } + switch amountValidation { + case .empty: + return "请填写结算金额" + case .invalidFormat: + return "金额格式错误,最多支持两位小数" + case .notPositive: + return "金额需大于 0" + case .valid: + return "" + } + } +} + +/// 景区结算金额校验结果。 +enum ScenicSettlementAmountValidation: Equatable { + case empty + case invalidFormat + case notPositive + case valid + + /// 对应的状态提示文案。 + var badgeText: String { + switch self { + case .empty: "待填写金额" + case .invalidFormat: "金额格式错误" + case .notPositive: "金额需大于 0" + case .valid: "金额有效" + } + } + + /// 对应的状态颜色。 + var badgeColor: UIColor { + switch self { + case .empty, .notPositive: + return AppDesign.warning + case .invalidFormat: + return UIColor(hex: 0xDC2626) + case .valid: + return AppDesign.success + } + } +} + +@MainActor +/// 景区结算审核 ViewModel,聚合景区申请和权限申请审核记录。 +final class ScenicSettlementReviewViewModel { + var onChange: (() -> Void)? + var scenicApplications: [ScenicApplicationPendingResponse] = [] { didSet { onChange?() } } + var roleApplications: [RoleApplyPendingResponse] = [] { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var message: String? { didSet { onChange?() } } + var loadFailedAll = false { didSet { onChange?() } } + var scenicLoadFailed = false { didSet { onChange?() } } + var roleLoadFailed = false { didSet { onChange?() } } + + /// 待审核记录数量。 + var pendingCount: Int { + scenicApplications.filter { $0.status == 1 }.count + roleApplications.filter { $0.status == 1 }.count + } + + /// 加载审核记录,单通道失败时保留另一通道数据。 + func load(api: any ScenicPermissionServing) async { + isLoading = true + loadFailedAll = false + scenicLoadFailed = false + roleLoadFailed = false + message = nil + defer { isLoading = false } + + var errors: [String] = [] + var scenicLoaded = false + var roleLoaded = false + + do { + scenicApplications = try await api.scenicApplicationPendingAll().items + scenicLoaded = true + } catch { + scenicApplications = [] + scenicLoadFailed = true + errors.append("景区申请记录加载失败") + } + + do { + roleApplications = try await api.roleApplyAll() + roleLoaded = true + } catch { + roleApplications = [] + roleLoadFailed = true + errors.append("权限申请记录加载失败") + } + + loadFailedAll = !scenicLoaded && !roleLoaded + if !errors.isEmpty { + message = errors.joined(separator: ";") + } + } + + /// 审核状态文案。 + func statusText(_ status: Int, fallback: String = "") -> String { + let trimmed = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + switch status { + case 1: return "待审核" + case 2: return "已通过" + case 3: return "已驳回" + case 9: return "已取消" + default: return "未知" + } + } + + /// 审核状态图标。 + func statusIcon(_ status: Int) -> String { + switch status { + case 2: return "checkmark.circle.fill" + case 3: return "xmark.octagon.fill" + case 1: return "clock.badge.exclamationmark.fill" + default: return "questionmark.circle.fill" + } + } + + /// 审核状态颜色。 + func statusColor(_ status: Int) -> UIColor { + switch status { + case 2: return AppDesign.success + case 3: return UIColor(hex: 0xDC2626) + case 1: return AppDesign.warning + default: return AppDesign.textSecondary + } + } +} diff --git a/suixinkan_ios/Features/Schedule/API/ScheduleAPI.swift b/suixinkan_ios/Features/Schedule/API/ScheduleAPI.swift new file mode 100644 index 0000000..01daad6 --- /dev/null +++ b/suixinkan_ios/Features/Schedule/API/ScheduleAPI.swift @@ -0,0 +1,91 @@ +// +// ScheduleAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 排班服务协议,抽象排班列表、新增、删除和可关联订单接口。 +@MainActor +protocol ScheduleServing { + /// 获取指定月份有排班的日期集合。 + func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] + + /// 获取指定日期的排班列表。 + func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] + + /// 新增排班。 + func addSchedule(_ request: AddScheduleRequest) async throws + + /// 删除排班。 + func deleteSchedule(id: Int) async throws + + /// 获取排班可关联订单。 + func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] +} + +/// 排班 API,封装摄影师排班相关接口。 +@MainActor +final class ScheduleAPI: ScheduleServing { + private let client: APIClient + + /// 初始化排班 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取指定月份有排班的日期集合。 + func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/schedule/list-date", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "date", value: yearMonth) + ] + ) + ) + } + + /// 获取指定日期的排班列表。 + func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/schedule/list", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "date", value: date) + ] + ) + ) + } + + /// 新增排班。 + func addSchedule(_ request: AddScheduleRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/schedule/add", body: request) + ) as EmptyPayload + } + + /// 删除排班。 + func deleteSchedule(id: Int) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/schedule/delete", body: ScheduleDeleteRequest(id: id)) + ) as EmptyPayload + } + + /// 获取排班可关联订单。 + func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/task/available-order", + queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")] + ) + ) + } +} diff --git a/suixinkan_ios/Features/Schedule/Models/ScheduleModels.swift b/suixinkan_ios/Features/Schedule/Models/ScheduleModels.swift new file mode 100644 index 0000000..0f14f9a --- /dev/null +++ b/suixinkan_ios/Features/Schedule/Models/ScheduleModels.swift @@ -0,0 +1,140 @@ +// +// ScheduleModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 排班日程实体,表示某一天的一条工作安排。 +struct ScheduleItem: Decodable, Identifiable, Hashable { + let id: Int + let name: String + let remark: String + let startTime: String + let endTime: String + let scheduleDate: String + let orderNumber: String? + let userNickname: String? + let userPhone: String? + let orderStatus: Int? + let orderStatusName: String? + + /// 字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case remark + case startTime = "start_time" + case endTime = "end_time" + case scheduleDate = "schedule_date" + case orderNumber = "order_number" + case userNickname = "user_nickname" + case userPhone = "user_phone" + case orderStatus = "order_status" + case orderStatusName = "order_status_name" + } + + /// 宽松解码排班字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeScheduleLossyInt(forKey: .id) ?? 0 + name = try container.decodeScheduleLossyString(forKey: .name) + remark = try container.decodeScheduleLossyString(forKey: .remark) + startTime = try container.decodeScheduleLossyString(forKey: .startTime) + endTime = try container.decodeScheduleLossyString(forKey: .endTime) + scheduleDate = try container.decodeScheduleLossyString(forKey: .scheduleDate) + orderNumber = container.decodeScheduleLossyOptionalString(forKey: .orderNumber) + userNickname = container.decodeScheduleLossyOptionalString(forKey: .userNickname) + userPhone = container.decodeScheduleLossyOptionalString(forKey: .userPhone) + orderStatus = try container.decodeScheduleLossyInt(forKey: .orderStatus) + orderStatusName = container.decodeScheduleLossyOptionalString(forKey: .orderStatusName) + } +} + +/// 新增排班请求实体。 +struct AddScheduleRequest: Encodable, Equatable { + let scenicId: String + let name: String + let remark: String + let startTime: String + let endTime: String + let scheduleDate: String + let orderNumber: String? + + /// 请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case name + case remark + case startTime = "start_time" + case endTime = "end_time" + case scheduleDate = "schedule_date" + case orderNumber = "order_number" + } +} + +/// 删除排班请求实体。 +struct ScheduleDeleteRequest: Encodable, Equatable { + let id: Int +} + +/// 排班编辑表单实体,用于 ViewModel 校验和生成请求。 +struct ScheduleDraft: Equatable { + var name = "" + var remark = "" + var scheduleDate = "" + var startTime = "" + var endTime = "" + var manualOrderNumber = "" + var selectedOrder: AvailableOrderResponse? + + /// 计算最终订单号,优先使用选择的订单,手工输入作为兜底。 + func finalOrderNumber() -> String? { + let picked = selectedOrder?.orderNumber.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let manual = manualOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines) + let value = picked.isEmpty ? manual : picked + return value.isEmpty ? nil : value + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeScheduleLossyString(forKey key: Key) throws -> String { + decodeScheduleLossyOptionalString(forKey: key) ?? "" + } + + /// 将 String、数字和 Bool 宽松解码为可选字符串。 + func decodeScheduleLossyOptionalString(forKey key: Key) -> String? { + if let value = try? decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : 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 ? "1" : "0" + } + return nil + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeScheduleLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(text) ?? Double(text).map(Int.init) + } + return nil + } +} diff --git a/suixinkan_ios/Features/Schedule/Schedule.md b/suixinkan_ios/Features/Schedule/Schedule.md new file mode 100644 index 0000000..951bf02 --- /dev/null +++ b/suixinkan_ios/Features/Schedule/Schedule.md @@ -0,0 +1,27 @@ +# Schedule 模块业务逻辑 + +## 模块职责 + +Schedule 模块负责首页 `schedule_management` 入口,提供排班日历、某日排班列表、新增排班和删除排班能力。 + +模块状态只保存在 `ScheduleManagementViewModel` 和 `ScheduleAddViewModel` 内,不写入全局登录态、账号上下文或首页状态。 + +## 排班列表 + +`ScheduleManagementViewModel` 按当前月份加载有排班的日期标记,并按选中日期加载日排班列表。切换月份或日期时只刷新当前模块数据。 + +缺少当前景区时,ViewModel 会清空月份标记和日程列表,并展示缺少经营上下文的空状态。 + +## 新增排班 + +`ScheduleAddViewModel` 管理名称、备注、日期、开始/结束时间和关联订单。关联订单可以从接口返回的可选订单中选择,也可以手填订单号兜底。 + +提交前会校验名称、景区、日期和时间顺序;重复提交会被忽略。提交成功后返回排班列表并刷新当前日期。 + +## 接口边界 + +`ScheduleAPI` 封装月排班日期、日排班列表、新增排班、删除排班和可关联订单接口。可关联订单模型放在共享业务模型中,避免 Schedule 直接依赖 Tasks 模块。 + +## 缓存边界 + +排班表单、关联订单、月标记和日程列表都只保存在内存中,不做本地缓存。 diff --git a/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift b/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift new file mode 100644 index 0000000..314e878 --- /dev/null +++ b/suixinkan_ios/Features/Schedule/ViewControllers/ScheduleViewControllers.swift @@ -0,0 +1,149 @@ +// +// ScheduleViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 排班管理页。 +final class ScheduleManagementViewController: ModuleTableViewController { + private let viewModel = ScheduleManagementViewModel() + + override func viewDidLoad() { + title = "排班管理" + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(title: "新增", style: .plain, target: self, action: #selector(addSchedule)), + UIBarButtonItem(title: "下月", style: .plain, target: self, action: #selector(nextMonth)) + ] + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "上月", + style: .plain, + target: self, + action: #selector(previousMonth) + ) + super.viewDidLoad() + wireViewModel(viewModel) { [weak self] in self?.updateTitle() } + } + + override func tableRowCount() -> Int { + viewModel.items.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + cell.configure( + title: item.name, + subtitle: "\(item.startTime) - \(item.endTime)", + detail: item.orderNumber ?? item.remark + ) + } + + override func didSelectTableRow(at indexPath: IndexPath) { + let item = viewModel.items[indexPath.row] + let alert = UIAlertController(title: item.name, message: "删除该排班?", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in + guard let self else { return } + Task { await self.viewModel.delete(item: item, api: self.services.scheduleAPI, scenicId: self.services.currentScenicId) } + }) + present(alert, animated: true) + } + + override func reloadContent() async { + await viewModel.reload(api: services.scheduleAPI, scenicId: services.currentScenicId) + updateTitle() + } + + private func updateTitle() { + let month = viewModel.monthDate.scheduleYearMonthText + title = "排班 \(month)" + } + + @objc private func previousMonth() { + Task { await viewModel.previousMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) } + } + + @objc private func nextMonth() { + Task { await viewModel.nextMonth(api: services.scheduleAPI, scenicId: services.currentScenicId) } + } + + @objc private func addSchedule() { + navigationController?.pushViewController(ScheduleAddViewController(), animated: true) + } +} + +extension ScheduleManagementViewModel: ViewModelBindable {} + +/// 新增排班页。 +final class ScheduleAddViewController: ModuleTableViewController { + private let viewModel = ScheduleAddViewModel() + private let nameField = UITextField() + private let remarkField = UITextField() + + override func viewDidLoad() { + title = "新增排班" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "提交", + style: .done, + target: self, + action: #selector(submit) + ) + super.viewDidLoad() + wireViewModel(viewModel) { } + setupFormHeader() + } + + private func setupFormHeader() { + nameField.placeholder = "日程名称" + nameField.borderStyle = .roundedRect + remarkField.placeholder = "备注" + remarkField.borderStyle = .roundedRect + let stack = UIStackView(arrangedSubviews: [nameField, remarkField]) + stack.axis = .vertical + stack.spacing = 8 + stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 96) + stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) + stack.isLayoutMarginsRelativeArrangement = true + tableView.tableHeaderView = stack + } + + override func tableRowCount() -> Int { + viewModel.availableOrders.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let order = viewModel.availableOrders[indexPath.row] + let selected = viewModel.draft.selectedOrder?.orderNumber == order.orderNumber + cell.configure(title: order.orderNumber, subtitle: order.projectName, detail: selected ? "已选择" : nil) + cell.accessoryType = selected ? .checkmark : .none + } + + override func didSelectTableRow(at indexPath: IndexPath) { + viewModel.draft.selectedOrder = viewModel.availableOrders[indexPath.row] + reloadTable() + } + + override func reloadContent() async { + await viewModel.loadOrders(api: services.scheduleAPI, scenicId: services.currentScenicId) + } + + @objc private func submit() { + viewModel.draft.name = nameField.text ?? "" + viewModel.draft.remark = remarkField.text ?? "" + Task { + let start = Date() + let end = Calendar.current.date(byAdding: .hour, value: 1, to: start) ?? start + let success = await viewModel.submit( + api: services.scheduleAPI, + scenicId: services.currentScenicId, + startDate: start, + endDate: end + ) + if success { navigationController?.popViewController(animated: true) } + } + } +} + +extension ScheduleAddViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift b/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift new file mode 100644 index 0000000..6ccce58 --- /dev/null +++ b/suixinkan_ios/Features/Schedule/ViewModels/ScheduleViewModels.swift @@ -0,0 +1,224 @@ +// +// ScheduleViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 排班错误实体,表示表单校验失败。 +enum ScheduleValidationError: LocalizedError, Equatable { + case missingScenic + case missingName + case invalidTime + + /// 错误文案。 + var errorDescription: String? { + switch self { + case .missingScenic: return "当前缺少景区信息" + case .missingName: return "请输入日程名称" + case .invalidTime: return "结束时间必须晚于开始时间" + } + } +} + +/// 排班管理 ViewModel,负责月份日期标记、某日列表和删除操作。 +@MainActor +final class ScheduleManagementViewModel { + var onChange: (() -> Void)? + var monthDate = Date.scheduleFirstDayOfMonth { didSet { onChange?() } } + var selectedDate = Date() { didSet { onChange?() } } + private(set) var markedDays: Set = [] { didSet { onChange?() } } + private(set) var items: [ScheduleItem] = [] { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 切换到上一个月并重新加载。 + func previousMonth(api: any ScheduleServing, scenicId: Int?) async { + monthDate = Calendar.current.date(byAdding: .month, value: -1, to: monthDate) ?? monthDate + selectedDate = monthDate + await reload(api: api, scenicId: scenicId) + } + + /// 切换到下一个月并重新加载。 + func nextMonth(api: any ScheduleServing, scenicId: Int?) async { + monthDate = Calendar.current.date(byAdding: .month, value: 1, to: monthDate) ?? monthDate + selectedDate = monthDate + await reload(api: api, scenicId: scenicId) + } + + /// 选择日期并加载当天排班。 + func selectDate(_ date: Date, api: any ScheduleServing, scenicId: Int?) async { + selectedDate = date + await loadDay(api: api, scenicId: scenicId) + } + + /// 重新加载月份日期标记和当前日排班。 + func reload(api: any ScheduleServing, scenicId: Int?) async { + guard let scenicId else { + reset() + return + } + loading = true + errorMessage = nil + defer { loading = false } + do { + async let days = api.monthScheduleDays(scenicId: scenicId, yearMonth: monthDate.scheduleYearMonthText) + async let list = api.dayScheduleList(scenicId: scenicId, date: selectedDate.scheduleDayText) + markedDays = Set(try await days) + items = try await list + } catch { + items = [] + markedDays = [] + errorMessage = error.localizedDescription + } + } + + /// 只加载当前选中日期的排班列表。 + func loadDay(api: any ScheduleServing, scenicId: Int?) async { + guard let scenicId else { + reset() + return + } + do { + items = try await api.dayScheduleList(scenicId: scenicId, date: selectedDate.scheduleDayText) + } catch { + items = [] + errorMessage = error.localizedDescription + } + } + + /// 删除排班后刷新当前日和月份标记。 + func delete(item: ScheduleItem, api: any ScheduleServing, scenicId: Int?) async { + do { + try await api.deleteSchedule(id: item.id) + await reload(api: api, scenicId: scenicId) + } catch { + errorMessage = error.localizedDescription + } + } + + private func reset() { + markedDays = [] + items = [] + loading = false + } +} + +/// 新增排班 ViewModel,负责可选订单加载、表单校验和提交。 +@MainActor +final class ScheduleAddViewModel { + var onChange: (() -> Void)? + var draft = ScheduleDraft() { didSet { onChange?() } } + private(set) var availableOrders: [AvailableOrderResponse] = [] { didSet { onChange?() } } + private(set) var loadingOrders = false { didSet { onChange?() } } + private(set) var submitting = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 加载可关联订单列表。 + func loadOrders(api: any ScheduleServing, scenicId: Int?) async { + guard let scenicId else { + availableOrders = [] + return + } + loadingOrders = true + defer { loadingOrders = false } + do { + availableOrders = try await api.availableOrderList(scenicId: scenicId) + } catch { + availableOrders = [] + errorMessage = error.localizedDescription + } + } + + /// 提交新增排班请求。 + func submit(api: any ScheduleServing, scenicId: Int?, startDate: Date, endDate: Date) async -> Bool { + guard !submitting else { return false } + guard let scenicId else { return fail(.missingScenic) } + let normalizedName = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { return fail(.missingName) } + guard endDate > startDate else { return fail(.invalidTime) } + + submitting = true + errorMessage = nil + defer { submitting = false } + do { + try await api.addSchedule( + AddScheduleRequest( + scenicId: "\(scenicId)", + name: normalizedName, + remark: draft.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? normalizedName : draft.remark.trimmingCharacters(in: .whitespacesAndNewlines), + startTime: startDate.scheduleHourMinuteText, + endTime: endDate.scheduleHourMinuteText, + scheduleDate: startDate.scheduleDayText, + orderNumber: draft.finalOrderNumber() + ) + ) + return true + } catch { + errorMessage = normalizedError(error) + return false + } + } + + /// 根据旧后端英文错误归一化为中文。 + private func normalizedError(_ error: Error) -> String { + let text = error.localizedDescription + if text.contains("start time") || text.contains("format H:i") { + return "开始时间格式不正确,请重新选择开始时间" + } + if text.contains("end time") { + return "结束时间格式不正确,请重新选择结束时间" + } + return text + } + + private func fail(_ error: ScheduleValidationError) -> Bool { + errorMessage = error.localizedDescription + return false + } +} + +extension Date { + /// 当前月份第一天。 + static var scheduleFirstDayOfMonth: Date { + Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Date())) ?? Date() + } + + /// yyyy-MM 格式的月份文本。 + var scheduleYearMonthText: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM" + return formatter.string(from: self) + } + + /// yyyy-MM-dd 格式的日期文本。 + var scheduleDayText: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: self) + } + + /// HH:mm 格式的时间文本。 + var scheduleHourMinuteText: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "HH:mm" + return formatter.string(from: self) + } + + /// 当前月份的全部日期。 + var scheduleDaysInMonth: [Date] { + let calendar = Calendar.current + let range = calendar.range(of: .day, in: .month, for: self) ?? 1..<2 + let components = calendar.dateComponents([.year, .month], from: self) + return range.compactMap { day in + var next = components + next.day = day + return calendar.date(from: next) + } + } +} diff --git a/suixinkan_ios/Features/Statistics/API/StatisticsAPI.swift b/suixinkan_ios/Features/Statistics/API/StatisticsAPI.swift new file mode 100644 index 0000000..f8f0ac3 --- /dev/null +++ b/suixinkan_ios/Features/Statistics/API/StatisticsAPI.swift @@ -0,0 +1,80 @@ +// +// StatisticsAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 数据统计服务协议,抽象汇总和每日明细读取能力以便测试替换。 +protocol StatisticsServing { + /// 获取统计汇总。 + func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse + + /// 获取每日统计分页列表。 + func dailyList( + scenicId: Int, + startTime: String, + endTime: String, + page: Int, + pageSize: Int, + isScenicAdmin: Bool + ) async throws -> DataListPayload +} + +@MainActor +/// 数据统计 API,封装摄影师和景区管理员两套统计接口。 +final class StatisticsAPI: StatisticsServing { + private let client: APIClient + + /// 初始化数据统计 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取统计汇总。 + func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse { + try await client.send( + APIRequest( + method: .get, + path: isScenicAdmin ? "/api/app/scenic-admin/analyse" : "/api/yf-handset-app/photog/analyse/user", + queryItems: [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "range", value: range) + ] + ) + ) + } + + /// 获取每日统计分页列表。 + func dailyList( + scenicId: Int, + startTime: String, + endTime: String, + page: Int = 1, + pageSize: Int = 15, + isScenicAdmin: Bool + ) async throws -> DataListPayload { + var query = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + if !startTime.isEmpty { + query.append(URLQueryItem(name: "start_time", value: startTime)) + } + if !endTime.isEmpty { + query.append(URLQueryItem(name: "end_time", value: endTime)) + } + + return try await client.send( + APIRequest( + method: .get, + path: isScenicAdmin ? "/api/app/scenic-admin/analyse/daily" : "/api/yf-handset-app/photog/analyse/user/daily", + queryItems: query + ) + ) + } +} diff --git a/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift b/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift new file mode 100644 index 0000000..5a577b7 --- /dev/null +++ b/suixinkan_ios/Features/Statistics/Models/StatisticsModels.swift @@ -0,0 +1,206 @@ +// +// StatisticsModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 数据统计时间段实体,表示看板顶部的快捷日期范围。 +enum StatisticsPeriod: String, CaseIterable, Identifiable { + case today = "今日" + case yesterday = "昨日" + case sevenDays = "7日" + case thisMonth = "本月" + + var id: String { rawValue } + + /// 后端汇总接口需要的范围标识。 + var apiRange: String { + switch self { + case .today: + return "1" + case .yesterday: + return "2" + case .sevenDays: + return "3" + case .thisMonth: + return "4" + } + } + + /// 当前时间段对应的本地日期区间。 + var interval: (start: Date, end: Date) { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + switch self { + case .today: + return (today, today) + case .yesterday: + let day = calendar.date(byAdding: .day, value: -1, to: today) ?? today + return (day, day) + case .sevenDays: + return (calendar.date(byAdding: .day, value: -6, to: today) ?? today, today) + case .thisMonth: + let components = calendar.dateComponents([.year, .month], from: today) + return (calendar.date(from: components) ?? today, today) + } + } + + /// 页面展示的时间范围文案。 + var selectedTimeText: String { + let dates = interval + return "\(Self.uiDateFormatter.string(from: dates.start))至\(Self.uiDateFormatter.string(from: dates.end))" + } + + /// 后端列表接口开始日期。 + var apiStartTime: String { + Self.apiDateFormatter.string(from: interval.start) + } + + /// 后端列表接口结束日期。 + var apiEndTime: String { + Self.apiDateFormatter.string(from: interval.end) + } + + private static let uiDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy/MM/dd" + return formatter + }() + + private static let apiDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} + +/// 数据统计汇总实体,表示订单金额、数量、客单价、实收和退款。 +struct StatisticsSummaryResponse: Decodable, Equatable { + let orderAmountSum: String + let orderCount: Int + let orderPriceAverage: String + let receivedAmountSum: String + let refundAmountSum: String + + var orderAmountValue: Double { Self.parseAmount(orderAmountSum) } + var orderPriceAverageValue: Double { Self.parseAmount(orderPriceAverage) } + var receivedAmountValue: Double { Self.parseAmount(receivedAmountSum) } + var refundAmountValue: Double { Self.parseAmount(refundAmountSum) } + + enum CodingKeys: String, CodingKey { + case orderAmountSum = "order_amount_sum" + case orderCount = "order_count" + case orderPriceAverage = "order_price_avg" + case receivedAmountSum = "received_amount_sum" + case refundAmountSum = "refund_amount_sum" + } + + /// 创建统计汇总实体,主要用于初始状态和测试。 + init( + orderAmountSum: String = "", + orderCount: Int = 0, + orderPriceAverage: String = "", + receivedAmountSum: String = "", + refundAmountSum: String = "" + ) { + self.orderAmountSum = orderAmountSum + self.orderCount = orderCount + self.orderPriceAverage = orderPriceAverage + self.receivedAmountSum = receivedAmountSum + self.refundAmountSum = refundAmountSum + } + + /// 宽松解码统计汇总字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + orderAmountSum = try container.decodeLossyString(forKey: .orderAmountSum) + orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0 + orderPriceAverage = try container.decodeLossyString(forKey: .orderPriceAverage) + receivedAmountSum = try container.decodeLossyString(forKey: .receivedAmountSum) + refundAmountSum = try container.decodeLossyString(forKey: .refundAmountSum) + } + + private static func parseAmount(_ text: String) -> Double { + let normalized = text.filter { "0123456789.-".contains($0) } + return Double(normalized) ?? 0 + } +} + +/// 每日统计实体,表示某天订单数、客单价、退款和实收。 +struct StatisticsDailyItem: Decodable, Identifiable, Equatable { + var id: String { date } + + let date: String + let orderCount: Int + let orderPrice: String + let refund: String + let received: String + + var orderPriceValue: Double { Self.parseAmount(orderPrice) } + var refundValue: Double { Self.parseAmount(refund) } + var receivedValue: Double { Self.parseAmount(received) } + + enum CodingKeys: String, CodingKey { + case date + case orderCount = "order_count" + case orderPrice = "order_price" + case refund + case received + } + + /// 宽松解码每日统计字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decodeLossyString(forKey: .date) + orderCount = try container.decodeLossyInt(forKey: .orderCount) ?? 0 + orderPrice = try container.decodeLossyString(forKey: .orderPrice) + refund = try container.decodeLossyString(forKey: .refund) + received = try container.decodeLossyString(forKey: .received) + } + + private static func parseAmount(_ text: String) -> Double { + let normalized = text.filter { "0123456789.-".contains($0) } + return Double(normalized) ?? 0 + } +} + +private extension KeyedDecodingContainer { + /// 将任意常见 JSON 值宽松解码成字符串。 + 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) + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码成整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan_ios/Features/Statistics/Statistics.md b/suixinkan_ios/Features/Statistics/Statistics.md new file mode 100644 index 0000000..d56a51e --- /dev/null +++ b/suixinkan_ios/Features/Statistics/Statistics.md @@ -0,0 +1,26 @@ +# Statistics 模块业务逻辑 + +## 模块职责 + +Statistics 模块负责登录后的数据 Tab,展示当前景区下的订单统计看板。 + +当前同步旧工程数据页的主要能力:时间段切换、统计汇总卡、每日明细、分页加载和下拉刷新。 + +## 核心对象 + +- `StatisticsView`:数据 Tab 根视图,读取当前景区、当前角色和统计 API。 +- `StatisticsViewModel`:管理时间段、汇总数据、每日明细、分页状态和加载状态。 +- `StatisticsAPI`:封装摄影师和景区管理员两套统计接口。 +- `StatisticsPeriod`:表示今日、昨日、7日、本月四个快捷时间段。 + +## 数据流程 + +页面从 `AccountContext.currentScenic` 获取当前景区 ID。缺少景区时显示空状态,不请求接口。 + +切换时间段时同时请求汇总接口和第一页日数据接口。汇总接口使用 `range` 参数,日数据接口使用 `start_time`、`end_time`、`page` 和 `page_size`。 + +`roleId == 53` 时使用景区管理员接口,其他角色使用摄影师接口。日数据响应使用 `DataListPayload` 解码,兼容后端返回 `data` 或 `list` 字段。 + +## 分页规则 + +每日明细第一页随刷新或时间段切换加载。列表滚动到底部后,如果当前数量小于 total,则继续加载下一页。加载更多失败时保留已有数据和当前页状态。 diff --git a/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift b/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift new file mode 100644 index 0000000..ccd9130 --- /dev/null +++ b/suixinkan_ios/Features/Statistics/ViewControllers/StatisticsViewController.swift @@ -0,0 +1,290 @@ +// +// StatisticsViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 数据 Tab 根页面,展示订单统计汇总和每日明细。 +final class StatisticsViewController: UIViewController { + + private let viewModel = StatisticsViewModel() + + private lazy var tableView: UITableView = { + let table = UITableView(frame: .zero, style: .grouped) + table.backgroundColor = AppDesignUIKit.pageBackground + table.dataSource = self + table.delegate = self + table.register(StatisticsSummaryCell.self, forCellReuseIdentifier: StatisticsSummaryCell.reuseID) + table.register(StatisticsDailyCell.self, forCellReuseIdentifier: StatisticsDailyCell.reuseID) + table.register(StatisticsPeriodCell.self, forCellReuseIdentifier: StatisticsPeriodCell.reuseID) + return table + }() + + private lazy var refreshControl = UIRefreshControl() + + override func viewDidLoad() { + super.viewDidLoad() + title = "数据" + view.backgroundColor = AppDesignUIKit.pageBackground + view.addSubview(tableView) + tableView.snp.makeConstraints { make in make.edges.equalToSuperview() } + refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged) + tableView.refreshControl = refreshControl + + viewModel.onChange = { [weak self] in self?.tableView.reloadData() } + appServices.accountContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } + appServices.permissionContext.onChange = { [weak self] in Task { await self?.reload(showLoading: true) } } + Task { await reload(showLoading: true) } + } + + @objc private func refreshPulled() { + Task { + await reload(showLoading: false) + refreshControl.endRefreshing() + } + } + + private var currentScenicId: Int? { appServices.accountContext.currentScenic?.id } + private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } + + private func reload(showLoading: Bool) async { + guard currentScenicId != nil else { + tableView.reloadData() + return + } + do { + try await appServices.globalLoading.withOptionalLoading(showLoading, message: "加载数据...") { + try await self.viewModel.reload( + api: self.appServices.statisticsAPI, + scenicId: self.currentScenicId, + roleId: self.currentRoleId, + showLoading: false + ) + } + } catch { + showToast(error.localizedDescription) + } + } + + private func selectPeriod(_ period: StatisticsPeriod) { + Task { + do { + try await appServices.globalLoading.withLoading(message: "加载数据...") { + try await viewModel.selectPeriod( + period, + api: appServices.statisticsAPI, + scenicId: currentScenicId, + roleId: currentRoleId + ) + } + } catch { + showToast(error.localizedDescription) + } + } + } + + private func loadMore() async { + do { + try await viewModel.loadMore( + api: appServices.statisticsAPI, + scenicId: currentScenicId, + roleId: currentRoleId + ) + } catch { + showToast(error.localizedDescription) + } + } + + private func amountText(_ value: Double) -> String { + "¥\(String(format: "%.2f", value))" + } +} + +extension StatisticsViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + currentScenicId == nil ? 1 : 3 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if currentScenicId == nil { return 1 } + switch section { + case 0: return 1 + case 1: return 1 + default: + if viewModel.loading && viewModel.dailyItems.isEmpty { return 0 } + return max(viewModel.dailyItems.count, 1) + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if currentScenicId == nil { + let cell = UITableViewCell() + cell.selectionStyle = .none + cell.backgroundColor = .clear + cell.contentView.subviews.forEach { $0.removeFromSuperview() } + let empty = makeEmptyStateView(title: "缺少经营上下文", message: "请先在首页选择景区后查看数据看板。", systemImage: "chart.bar.doc.horizontal") + cell.contentView.addSubview(empty) + empty.snp.makeConstraints { make in make.edges.equalToSuperview(); make.height.equalTo(360) } + return cell + } + + switch indexPath.section { + case 0: + let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsPeriodCell.reuseID, for: indexPath) as! StatisticsPeriodCell + cell.configure(selectedPeriod: viewModel.selectedPeriod) { [weak self] period in + self?.selectPeriod(period) + } + return cell + case 1: + let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsSummaryCell.reuseID, for: indexPath) as! StatisticsSummaryCell + cell.configure( + periodText: viewModel.selectedPeriod.selectedTimeText, + summary: viewModel.summary, + amountText: amountText + ) + return cell + default: + if viewModel.dailyItems.isEmpty { + let cell = UITableViewCell() + cell.textLabel?.text = "暂无数据" + cell.selectionStyle = .none + return cell + } + let cell = tableView.dequeueReusableCell(withIdentifier: StatisticsDailyCell.reuseID, for: indexPath) as! StatisticsDailyCell + cell.configure(item: viewModel.dailyItems[indexPath.row], amountText: amountText) + return cell + } + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + section == 2 ? "每日明细" : nil + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard indexPath.section == 2, indexPath.row == viewModel.dailyItems.count - 1 else { return } + Task { await loadMore() } + } +} + +private final class StatisticsPeriodCell: UITableViewCell { + static let reuseID = "StatisticsPeriodCell" + private var onSelect: ((StatisticsPeriod) -> Void)? + private let stack = UIStackView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + stack.axis = .horizontal + stack.spacing = 8 + stack.distribution = .fillEqually + contentView.addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(selectedPeriod: StatisticsPeriod, onSelect: @escaping (StatisticsPeriod) -> Void) { + self.onSelect = onSelect + stack.arrangedSubviews.forEach { $0.removeFromSuperview() } + for period in StatisticsPeriod.allCases { + let button = UIButton(type: .system) + button.setTitle(period.rawValue, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: period == selectedPeriod ? .semibold : .regular) + button.setTitleColor(period == selectedPeriod ? AppDesignUIKit.primary : AppDesignUIKit.textSecondary, for: .normal) + button.backgroundColor = period == selectedPeriod ? AppDesignUIKit.primarySoft : UIColor(hex: 0xF4F4F4) + button.layer.cornerRadius = 6 + button.tag = StatisticsPeriod.allCases.firstIndex(of: period) ?? 0 + button.addTarget(self, action: #selector(periodTapped(_:)), for: .touchUpInside) + stack.addArrangedSubview(button) + } + } + + @objc private func periodTapped(_ sender: UIButton) { + let period = StatisticsPeriod.allCases[sender.tag] + onSelect?(period) + } +} + +private final class StatisticsSummaryCell: UITableViewCell { + static let reuseID = "StatisticsSummaryCell" + private let stack = UIStackView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + stack.axis = .vertical + stack.spacing = 8 + contentView.addSubview(stack) + stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(periodText: String, summary: StatisticsSummaryResponse, amountText: (Double) -> String) { + stack.arrangedSubviews.forEach { $0.removeFromSuperview() } + let dateLabel = UILabel() + dateLabel.text = "已选日期:\(periodText)" + dateLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) + stack.addArrangedSubview(dateLabel) + + stack.addArrangedSubview(summaryCard("订单总金额", amountText(summary.orderAmountValue), UIColor(hex: 0x22C55E))) + let row = UIStackView() + row.axis = .horizontal + row.spacing = 8 + row.distribution = .fillEqually + row.addArrangedSubview(summaryCard("订单总数", "\(summary.orderCount)单", UIColor(hex: 0x7F00FF))) + row.addArrangedSubview(summaryCard("实收金额", amountText(summary.receivedAmountValue), UIColor(hex: 0x22C55E))) + stack.addArrangedSubview(row) + + let row2 = UIStackView() + row2.axis = .horizontal + row2.spacing = 8 + row2.distribution = .fillEqually + row2.addArrangedSubview(summaryCard("客单价", amountText(summary.orderPriceAverageValue), AppDesignUIKit.primary)) + row2.addArrangedSubview(summaryCard("退款金额", amountText(summary.refundAmountValue), UIColor(hex: 0xEF4444))) + stack.addArrangedSubview(row2) + } + + private func summaryCard(_ title: String, _ value: String, _ color: UIColor) -> UIView { + let card = UIView() + card.backgroundColor = color.withAlphaComponent(0.08) + card.layer.cornerRadius = 8 + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + let valueLabel = UILabel() + valueLabel.text = value + valueLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .bold) + valueLabel.textColor = color + let inner = UIStackView(arrangedSubviews: [titleLabel, valueLabel]) + inner.axis = .vertical + inner.spacing = 4 + card.addSubview(inner) + inner.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) } + card.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(72) } + return card + } +} + +private final class StatisticsDailyCell: UITableViewCell { + static let reuseID = "StatisticsDailyCell" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + selectionStyle = .none + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + func configure(item: StatisticsDailyItem, amountText: (Double) -> String) { + textLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline, weight: .semibold) + detailTextLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption) + textLabel?.text = item.date + detailTextLabel?.text = "\(item.orderCount)单 · 实收 \(amountText(item.receivedValue))" + } +} diff --git a/suixinkan_ios/Features/Statistics/ViewModels/StatisticsViewModel.swift b/suixinkan_ios/Features/Statistics/ViewModels/StatisticsViewModel.swift new file mode 100644 index 0000000..070d851 --- /dev/null +++ b/suixinkan_ios/Features/Statistics/ViewModels/StatisticsViewModel.swift @@ -0,0 +1,99 @@ +// +// StatisticsViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 数据统计页面 ViewModel,管理时间段、汇总数据、每日明细和分页状态。 +final class StatisticsViewModel { + var onChange: (() -> Void)? + var selectedPeriod: StatisticsPeriod = .today { didSet { onChange?() } } + private(set) var loading = false { didSet { onChange?() } } + private(set) var loadingMore = false { didSet { onChange?() } } + private(set) var summary = StatisticsSummaryResponse() { didSet { onChange?() } } + private(set) var dailyItems: [StatisticsDailyItem] = [] { didSet { onChange?() } } + private(set) var totalDailyCount = 0 { didSet { onChange?() } } + private(set) var lastRefreshAt: Date? { didSet { onChange?() } } + + private var page = 1 + private let pageSize = 15 + + /// 当前日数据是否还有下一页。 + var hasMore: Bool { + dailyItems.count < totalDailyCount + } + + /// 切换统计时间段,并重置分页重新加载。 + func selectPeriod(_ period: StatisticsPeriod, api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws { + selectedPeriod = period + try await reload(api: api, scenicId: scenicId, roleId: roleId) + } + + /// 刷新统计汇总和第一页日数据,缺少景区时清空旧状态。 + func reload(api: StatisticsServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws { + guard let scenicId else { + reset() + return + } + + if showLoading { loading = true } + defer { if showLoading { loading = false } } + + let isScenicAdmin = roleId == 53 + async let summaryResult = api.summary( + scenicId: scenicId, + range: selectedPeriod.apiRange, + isScenicAdmin: isScenicAdmin + ) + async let dailyResult = api.dailyList( + scenicId: scenicId, + startTime: selectedPeriod.apiStartTime, + endTime: selectedPeriod.apiEndTime, + page: 1, + pageSize: pageSize, + isScenicAdmin: isScenicAdmin + ) + let (summaryData, dailyData) = try await (summaryResult, dailyResult) + summary = summaryData + dailyItems = dailyData.data + totalDailyCount = dailyData.total + page = 1 + lastRefreshAt = Date() + } + + /// 加载每日统计下一页。 + func loadMore(api: StatisticsServing, scenicId: Int?, roleId: Int?) async throws { + guard !loadingMore, hasMore, let scenicId else { return } + + loadingMore = true + defer { loadingMore = false } + + let nextPage = page + 1 + let result = try await api.dailyList( + scenicId: scenicId, + startTime: selectedPeriod.apiStartTime, + endTime: selectedPeriod.apiEndTime, + page: nextPage, + pageSize: pageSize, + isScenicAdmin: roleId == 53 + ) + dailyItems.append(contentsOf: result.data) + totalDailyCount = result.total + page = nextPage + } + + /// 清空统计状态。 + private func reset() { + summary = StatisticsSummaryResponse() + dailyItems = [] + totalDailyCount = 0 + page = 1 + lastRefreshAt = nil + loading = false + loadingMore = false + } +} diff --git a/suixinkan_ios/Features/Tasks/API/TaskAPI.swift b/suixinkan_ios/Features/Tasks/API/TaskAPI.swift new file mode 100644 index 0000000..946b001 --- /dev/null +++ b/suixinkan_ios/Features/Tasks/API/TaskAPI.swift @@ -0,0 +1,146 @@ +// +// TaskAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 任务服务协议,抽象任务列表、详情、发布和云盘选择接口以便测试替换。 +@MainActor +protocol TaskServing { + /// 获取摄影师任务列表。 + func taskList( + scenicId: Int, + page: Int, + pageSize: Int, + taskStatus: Int, + taskName: String?, + startTime: String?, + endTime: String? + ) async throws -> ListPayload + + /// 获取任务详情。 + func taskDetail(id: Int) async throws -> TaskDetailResponse + + /// 获取发布任务时可关联的订单列表。 + func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] + + /// 发布任务。 + func addTask(_ request: AddTaskRequest) async throws + + /// 获取云盘文件列表,供发布任务选择附件。 + func cloudFileList( + parentFolderId: Int, + name: String, + type: Int, + orderBy: Int, + page: Int, + pageSize: Int + ) async throws -> ListPayload +} + +/// 任务 API,封装任务管理和发布任务相关接口。 +@MainActor +final class TaskAPI: TaskServing { + private let client: APIClient + + /// 初始化任务 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取摄影师任务列表。 + func taskList( + scenicId: Int, + page: Int = 1, + pageSize: Int = 10, + taskStatus: Int = 0, + taskName: String? = nil, + startTime: String? = nil, + endTime: String? = nil + ) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "scenic_id", value: "\(scenicId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"), + URLQueryItem(name: "task_status", value: "\(max(taskStatus, 0))") + ] + if let taskName = taskName?.trimmingCharacters(in: .whitespacesAndNewlines), !taskName.isEmpty { + query.append(URLQueryItem(name: "task_name", value: taskName)) + } + if let startTime, !startTime.isEmpty { + query.append(URLQueryItem(name: "start_time", value: startTime)) + } + if let endTime, !endTime.isEmpty { + query.append(URLQueryItem(name: "end_time", value: endTime)) + } + + return try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/task/list", + queryItems: query + ) + ) + } + + /// 获取任务详情。 + func taskDetail(id: Int) async throws -> TaskDetailResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/task/info", + queryItems: [URLQueryItem(name: "id", value: "\(id)")] + ) + ) + } + + /// 获取发布任务时可关联的订单列表。 + func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/task/available-order", + queryItems: [URLQueryItem(name: "scenic_id", value: "\(scenicId)")] + ) + ) + } + + /// 发布任务。 + func addTask(_ request: AddTaskRequest) async throws { + _ = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/task/create", + body: request + ) + ) as EmptyPayload + } + + /// 获取云盘文件列表,供发布任务选择附件。 + func cloudFileList( + parentFolderId: Int, + name: String = "", + type: Int = 0, + orderBy: Int = 2, + page: Int = 1, + pageSize: Int = 20 + ) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/cloud-driver/list", + queryItems: [ + URLQueryItem(name: "parent_folder_id", value: "\(parentFolderId)"), + URLQueryItem(name: "name", value: name), + URLQueryItem(name: "type", value: "\(type)"), + URLQueryItem(name: "order_by", value: "\(orderBy)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } +} diff --git a/suixinkan_ios/Features/Tasks/Models/TaskModels.swift b/suixinkan_ios/Features/Tasks/Models/TaskModels.swift new file mode 100644 index 0000000..8ed362a --- /dev/null +++ b/suixinkan_ios/Features/Tasks/Models/TaskModels.swift @@ -0,0 +1,291 @@ +// +// TaskModels.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 摄影师任务列表实体,表示任务管理页中的一条任务记录。 +struct PhotographerTaskItem: Decodable, Identifiable, Hashable { + let id: Int + let name: String + let type: Int + let orderNumber: String + let taskStatus: Int + let statusName: String + let createdAt: String + let updatedAt: String + let photogRemark: String + let editorName: String + let operateTime: String + let orderUserNickname: String + let orderUserPhone: String + let orderUserId: String + let media: [TaskMediaItem] + + /// 任务列表字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case type + case orderNumber = "order_number" + case taskStatus = "task_status" + case statusName = "status_name" + case createdAt = "created_at" + case updatedAt = "updated_at" + case photogRemark = "photog_remark" + case editorName = "editor_name" + case operateTime = "operate_time" + case orderUserNickname = "order_user_nickname" + case orderUserPhone = "order_user_phone" + case orderUserId = "order_user_id" + case media + } + + /// 宽松解码任务列表字段,避免后端数字和字符串混用导致页面不可用。 + 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) + type = try container.decodeLossyInt(forKey: .type) ?? 0 + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + taskStatus = try container.decodeLossyInt(forKey: .taskStatus) ?? 0 + statusName = try container.decodeLossyString(forKey: .statusName) + createdAt = try container.decodeLossyString(forKey: .createdAt) + updatedAt = try container.decodeLossyString(forKey: .updatedAt) + photogRemark = try container.decodeLossyString(forKey: .photogRemark) + editorName = try container.decodeLossyString(forKey: .editorName) + operateTime = try container.decodeLossyString(forKey: .operateTime) + orderUserNickname = try container.decodeLossyString(forKey: .orderUserNickname) + orderUserPhone = try container.decodeLossyString(forKey: .orderUserPhone) + orderUserId = try container.decodeLossyString(forKey: .orderUserId) + media = (try? container.decode([TaskMediaItem].self, forKey: .media)) ?? [] + } +} + +/// 任务媒体实体,表示任务附件或任务结果中的图片、视频文件。 +struct TaskMediaItem: Decodable, Identifiable, Hashable { + let id: Int + let photogTaskId: Int + let userId: Int + let fileName: String + let fileType: Int + let fileUrl: String + let fileSize: Int64 + let coverUrl: String + let duration: Int + let remark: String + let createdAt: String + let updatedAt: String + + /// 判断当前媒体是否应按视频展示。 + var isVideo: Bool { + fileType == 1 || Self.hasVideoExtension(fileName) || Self.hasVideoExtension(fileUrl) + } + + /// 返回用于缩略图展示的 URL,优先使用封面图。 + var displayURL: String { + coverUrl.isEmpty ? fileUrl : coverUrl + } + + /// 根据文件扩展名判断是否是常见视频格式。 + private static func hasVideoExtension(_ value: String) -> Bool { + ["mp4", "mov", "m4v", "avi"].contains(URL(string: value)?.pathExtension.lowercased() ?? URL(fileURLWithPath: value).pathExtension.lowercased()) + } + + /// 任务媒体字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case photogTaskId = "photog_task_id" + case userId = "user_id" + case fileName = "file_name" + case fileType = "file_type" + case fileUrl = "file_url" + case fileSize = "file_size" + case coverUrl = "cover_url" + case duration + case remark + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + /// 宽松解码任务媒体字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + photogTaskId = try container.decodeLossyInt(forKey: .photogTaskId) ?? 0 + userId = try container.decodeLossyInt(forKey: .userId) ?? 0 + fileName = try container.decodeLossyString(forKey: .fileName) + fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0 + fileUrl = try container.decodeLossyString(forKey: .fileUrl) + fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0) + coverUrl = try container.decodeLossyString(forKey: .coverUrl) + duration = try container.decodeLossyInt(forKey: .duration) ?? 0 + remark = try container.decodeLossyString(forKey: .remark) + createdAt = try container.decodeLossyString(forKey: .createdAt) + updatedAt = try container.decodeLossyString(forKey: .updatedAt) + } +} + +/// 任务详情实体,表示任务详情页展示的完整任务信息。 +struct TaskDetailResponse: Decodable, Identifiable, Hashable { + let id: Int + let name: String + let createdAt: String + let taskStatus: Int + let taskStatusLabel: String + let acceptedAt: String + let editorName: String + let editorPhone: String + let urgentHour: Int + let photogRemark: String + let order: TaskDetailOrder? + let taskResult: [TaskMediaItem] + + /// 任务详情字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case id + case name + case createdAt = "created_at" + case taskStatus = "task_status" + case taskStatusLabel = "task_status_label" + case acceptedAt = "accepted_at" + case editorName = "editor_name" + case editorPhone = "editor_phone" + case urgentHour = "urgent_hour" + case photogRemark = "photog_remark" + case order + case taskResult = "task_result" + } + + /// 宽松解码任务详情字段。 + 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) + createdAt = try container.decodeLossyString(forKey: .createdAt) + taskStatus = try container.decodeLossyInt(forKey: .taskStatus) ?? 0 + taskStatusLabel = try container.decodeLossyString(forKey: .taskStatusLabel) + acceptedAt = try container.decodeLossyString(forKey: .acceptedAt) + editorName = try container.decodeLossyString(forKey: .editorName) + editorPhone = try container.decodeLossyString(forKey: .editorPhone) + urgentHour = try container.decodeLossyInt(forKey: .urgentHour) ?? 0 + photogRemark = try container.decodeLossyString(forKey: .photogRemark) + order = try? container.decode(TaskDetailOrder.self, forKey: .order) + taskResult = (try? container.decode([TaskMediaItem].self, forKey: .taskResult)) ?? [] + } +} + +/// 任务关联订单实体,表示任务详情中的订单摘要。 +struct TaskDetailOrder: Decodable, Hashable { + let projectName: String + let orderNumber: String + let payTime: String + let userPhone: String + + /// 任务关联订单字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case projectName = "project_name" + case orderNumber = "order_number" + case payTime = "pay_time" + case userPhone = "user_phone" + } + + /// 宽松解码订单摘要字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + projectName = try container.decodeLossyString(forKey: .projectName) + orderNumber = try container.decodeLossyString(forKey: .orderNumber) + payTime = try container.decodeLossyString(forKey: .payTime) + userPhone = try container.decodeLossyString(forKey: .userPhone) + } +} + +/// 发布任务请求实体,描述提交给后端的新任务内容。 +struct AddTaskRequest: Encodable, Equatable { + let scenicId: String + let name: String + let orderNumber: String? + let remark: String + let urgentHour: Int + let cloudFile: [CloudFileItem] + let uploadFile: [UploadFileItem] + + /// 发布任务请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case scenicId = "scenic_id" + case name + case orderNumber = "order_number" + case remark = "photog_remark" + case urgentHour = "urgent_hour" + case cloudFile = "cloud_file" + case uploadFile = "upload_file" + } +} + +/// 任务云盘附件请求实体,表示发布任务时选择的云盘文件。 +struct CloudFileItem: Encodable, Equatable { + let fileId: Int + let remark: String + + /// 云盘附件请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + case remark + } +} + +/// 任务上传附件请求实体,表示发布任务时上传到 OSS 的本地文件。 +struct UploadFileItem: Encodable, Equatable { + let fileUrl: String + let fileName: String + let remark: String + + /// 上传附件请求字段映射,兼容后端下划线命名。 + enum CodingKeys: String, CodingKey { + case fileUrl = "file_url" + case fileName = "file_name" + case remark + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return String(value) + } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { + return value ? "true" : "false" + } + return "" + } + + /// 将 Int、Double 或数字字符串宽松解码为整数。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? decodeIfPresent(String.self, forKey: key) { + let text = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let intValue = Int(text) { + return intValue + } + if let doubleValue = Double(text) { + return Int(doubleValue) + } + } + if let value = try? decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + return nil + } +} diff --git a/suixinkan_ios/Features/Tasks/Tasks.md b/suixinkan_ios/Features/Tasks/Tasks.md new file mode 100644 index 0000000..db54a84 --- /dev/null +++ b/suixinkan_ios/Features/Tasks/Tasks.md @@ -0,0 +1,30 @@ +# Tasks 模块业务逻辑 + +## 模块职责 + +Tasks 模块负责首页“任务管理”和“发布任务”入口,包括任务列表、筛选、分页、任务详情、可关联订单、云盘附件选择和本地附件 OSS 上传。 + +任务状态只保存在本模块 ViewModel 内,不写入 `AppSession`、`AccountContext` 或 TabBar。当前景区 ID 从 `AccountContext.currentScenic` 读取,缺少景区时不请求任务接口。 + +## 数据来源 + +`TaskAPI` 封装任务接口: +- `taskList` 获取任务列表。 +- `taskDetail` 获取任务详情。 +- `availableOrderList` 获取发布任务可关联订单。 +- `addTask` 提交发布任务。 +- `cloudFileList` 仅用于发布任务选择云盘附件,不代表完整云盘模块迁移。 + +任务本地附件上传复用 `OSSUploadService.uploadTaskFile`,上传成功后把最终 OSS URL 放入 `upload_file` 提交给发布任务接口。OSS STS、上传进度和本地文件数据不落盘。 + +## 页面流程 + +任务管理页支持状态筛选、任务名称搜索、开始/结束日期筛选、下拉刷新和加载更多。点击任务卡片进入任务详情页,详情接口失败时保留列表摘要信息作为兜底。 + +发布任务页支持填写任务名称、任务备注、紧急小时、选择关联订单、选择云盘文件和选择本地图片/视频。提交前先校验表单,再上传本地附件,最后调用发布任务接口。任意附件上传失败时不提交任务。 + +## 迁移边界 + +本轮不迁旧工程中把订单管理和核销订单拼成“待办任务”的逻辑;订单与核销已经归 `Features/Orders` 管理。 + +本轮不迁完整云盘资产管理,只保留发布任务所需的文件选择能力。云盘的新建文件夹、上传、删除、移动、重命名等能力后续应放在独立云盘模块中迁移。 diff --git a/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift b/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift new file mode 100644 index 0000000..15655b6 --- /dev/null +++ b/suixinkan_ios/Features/Tasks/ViewControllers/TaskViewControllers.swift @@ -0,0 +1,188 @@ +// +// TaskViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 任务管理列表页。 +final class TaskManagementViewController: ModuleTableViewController { + private let viewModel = TaskManagementViewModel() + + override func viewDidLoad() { + title = "任务管理" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "发布", + style: .plain, + target: self, + action: #selector(createTask) + ) + super.viewDidLoad() + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { + viewModel.tasks.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let task = viewModel.tasks[indexPath.row] + cell.configure(title: task.name, subtitle: task.statusName, detail: task.createdAt) + } + + override func didSelectTableRow(at indexPath: IndexPath) { + let task = viewModel.tasks[indexPath.row] + navigationController?.pushViewController( + TaskDetailViewController(taskId: task.id, summary: task), + animated: true + ) + } + + override func reloadContent() async { + try? await viewModel.reload(api: services.taskAPI, scenicId: services.currentScenicId) + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard indexPath.row >= viewModel.tasks.count - 2 else { return } + Task { try? await viewModel.loadMore(api: services.taskAPI, scenicId: services.currentScenicId) } + } + + @objc private func createTask() { + navigationController?.pushViewController(TaskCreateViewController(), animated: true) + } +} + +extension TaskManagementViewModel: ViewModelBindable {} + +/// 任务详情页。 +final class TaskDetailViewController: ModuleTableViewController { + private let taskId: Int + private let summary: PhotographerTaskItem? + private let viewModel = TaskDetailViewModel() + + init(taskId: Int, summary: PhotographerTaskItem?) { + self.taskId = taskId + self.summary = summary + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + title = summary?.name ?? "任务详情" + super.viewDidLoad() + wireViewModel(viewModel) { [weak self] in + self?.title = self?.viewModel.detail?.name ?? self?.summary?.name ?? "任务详情" + } + } + + override func tableRowCount() -> Int { + guard let detail = viewModel.detail else { return summary == nil ? 0 : 4 } + return 6 + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + if let detail = viewModel.detail { + switch indexPath.row { + case 0: cell.configure(title: "任务名称", subtitle: detail.name) + case 1: cell.configure(title: "状态", subtitle: detail.taskStatusLabel) + case 2: cell.configure(title: "订单号", subtitle: detail.order?.orderNumber ?? "--") + case 3: cell.configure(title: "备注", subtitle: detail.photogRemark) + case 4: cell.configure(title: "创建时间", subtitle: detail.createdAt) + default: cell.configure(title: "附件", subtitle: "\(detail.taskResult.count) 个") + } + } else if let summary { + switch indexPath.row { + case 0: cell.configure(title: "任务名称", subtitle: summary.name) + case 1: cell.configure(title: "状态", subtitle: summary.statusName) + case 2: cell.configure(title: "订单号", subtitle: summary.orderNumber) + default: cell.configure(title: "创建时间", subtitle: summary.createdAt) + } + } + } + + override func reloadContent() async { + await viewModel.load(api: services.taskAPI, taskId: taskId) + } +} + +extension TaskDetailViewModel: ViewModelBindable {} + +/// 发布任务页。 +final class TaskCreateViewController: ModuleTableViewController { + private let viewModel = TaskCreateViewModel() + private let nameField = UITextField() + private let remarkField = UITextField() + + override func viewDidLoad() { + title = "发布任务" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "提交", + style: .done, + target: self, + action: #selector(submit) + ) + super.viewDidLoad() + setupHeader() + wireViewModel(viewModel) { } + } + + private func setupHeader() { + nameField.placeholder = "任务名称" + nameField.borderStyle = .roundedRect + remarkField.placeholder = "备注" + remarkField.borderStyle = .roundedRect + let stack = UIStackView(arrangedSubviews: [nameField, remarkField]) + stack.axis = .vertical + stack.spacing = 8 + stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 96) + stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) + stack.isLayoutMarginsRelativeArrangement = true + tableView.tableHeaderView = stack + } + + override func tableRowCount() -> Int { + viewModel.availableOrders.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let order = viewModel.availableOrders[indexPath.row] + let selected = viewModel.selectedOrder?.orderNumber == order.orderNumber + cell.configure(title: order.orderNumber, subtitle: order.projectName, detail: selected ? "已选择" : nil) + cell.accessoryType = selected ? .checkmark : .none + } + + override func didSelectTableRow(at indexPath: IndexPath) { + viewModel.selectedOrder = viewModel.availableOrders[indexPath.row] + } + + override func reloadContent() async { + await viewModel.loadAvailableOrders(api: services.taskAPI, scenicId: services.currentScenicId) + } + + @objc private func submit() { + viewModel.taskName = nameField.text ?? "" + viewModel.remark = remarkField.text ?? "" + Task { + do { + let success = try await viewModel.submit( + api: services.taskAPI, + uploadService: services.ossUploadService, + scenicId: services.currentScenicId + ) + if success { + navigationController?.popViewController(animated: true) + } + } catch { + services.toastCenter.show(error.localizedDescription) + } + } + } +} + +extension TaskCreateViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift b/suixinkan_ios/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift new file mode 100644 index 0000000..395b95c --- /dev/null +++ b/suixinkan_ios/Features/Tasks/ViewModels/TaskCloudFileSelectionViewModel.swift @@ -0,0 +1,138 @@ +// +// TaskCloudFileSelectionViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 云盘文件类型筛选实体,表示发布任务选择附件时的过滤方式。 +enum TaskCloudFileFilter: Int, CaseIterable, Identifiable { + case all = 0 + case video = 1 + case image = 2 + + var id: Int { rawValue } + + /// 返回筛选项展示文案。 + var title: String { + switch self { + case .all: + "全部" + case .video: + "视频" + case .image: + "图片" + } + } +} + +/// 云盘文件选择 ViewModel,负责目录浏览、筛选、分页和附件选择。 +@MainActor +final class TaskCloudFileSelectionViewModel { + var onChange: (() -> Void)? + var path: [CloudDriveFile] = [CloudDriveFile(id: 0, name: "云盘")] { didSet { onChange?() } } + var files: [CloudDriveFile] = [] { didSet { onChange?() } } + var total = 0 { didSet { onChange?() } } + var page = 1 { didSet { onChange?() } } + var searchText = "" { didSet { onChange?() } } + var selectedFilter: TaskCloudFileFilter = .all { didSet { onChange?() } } + var selectedFiles: [CloudDriveFile] = [] { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 20 + + /// 当前目录 ID。 + var currentFolderId: Int { + path.last?.id ?? 0 + } + + /// 判断当前目录是否还有下一页。 + var hasMore: Bool { + files.count < total + } + + /// 重新加载当前目录第一页。 + func reload(api: any TaskServing) async throws { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let payload = try await requestFiles(api: api, page: 1) + page = 1 + files = payload.list + total = payload.total + } catch { + files = [] + total = 0 + page = 1 + errorMessage = error.localizedDescription + throw error + } + } + + /// 加载当前目录下一页。 + func loadMore(api: any TaskServing) async throws { + guard hasMore, !isLoadingMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + + let nextPage = page + 1 + let payload = try await requestFiles(api: api, page: nextPage) + page = nextPage + total = payload.total + files.append(contentsOf: payload.list) + } + + /// 进入指定文件夹并刷新列表。 + func enterFolder(_ folder: CloudDriveFile, api: any TaskServing) async throws { + guard folder.isFolder else { return } + path.append(folder) + try await reload(api: api) + } + + /// 回到指定面包屑目录并刷新列表。 + func popToFolder(at index: Int, api: any TaskServing) async throws { + guard path.indices.contains(index) else { return } + path = Array(path.prefix(index + 1)) + try await reload(api: api) + } + + /// 切换文件选中状态,文件夹不会被选为附件。 + func toggleSelection(_ file: CloudDriveFile) { + guard !file.isFolder else { return } + if let index = selectedFiles.firstIndex(where: { $0.id == file.id }) { + selectedFiles.remove(at: index) + } else { + selectedFiles.append(file) + } + } + + /// 判断指定云盘文件是否已选中。 + func isSelected(_ file: CloudDriveFile) -> Bool { + selectedFiles.contains { $0.id == file.id } + } + + /// 将当前选中的云盘文件转换为发布任务表单附件。 + func makeSelectionItems() -> [TaskCloudSelectionItem] { + selectedFiles.map { + TaskCloudSelectionItem(id: $0.id, fileName: $0.name, fileType: $0.type, remark: "") + } + } + + /// 组装并发送云盘文件列表请求。 + private func requestFiles(api: any TaskServing, page: Int) async throws -> ListPayload { + try await api.cloudFileList( + parentFolderId: currentFolderId, + name: searchText.trimmingCharacters(in: .whitespacesAndNewlines), + type: selectedFilter.rawValue, + orderBy: 2, + page: page, + pageSize: pageSize + ) + } +} diff --git a/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift b/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift new file mode 100644 index 0000000..363c950 --- /dev/null +++ b/suixinkan_ios/Features/Tasks/ViewModels/TaskCreateViewModel.swift @@ -0,0 +1,250 @@ +// +// TaskCreateViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 任务云盘选择项实体,表示发布任务表单中的云盘附件。 +struct TaskCloudSelectionItem: Identifiable, Hashable { + let id: Int + let fileName: String + let fileType: Int + var remark: String +} + +/// 任务本地上传项实体,表示发布任务表单中的本地图片或视频。 +struct TaskLocalUploadItem: Identifiable, Hashable { + let id: UUID + let data: Data + let fileName: String + let fileType: Int + var remark: String + var uploadedURL: String? + var progress: Int + var errorMessage: String? + + /// 判断当前文件是否正在上传。 + var isUploading: Bool { + progress > 0 && progress < 100 && uploadedURL == nil && errorMessage == nil + } + + /// 返回上传状态展示文案。 + var statusText: String { + if let errorMessage { return errorMessage } + if uploadedURL != nil { return "已上传" } + if progress > 0 { return "上传中 \(progress)%" } + return "待上传" + } +} + +/// 发布任务 ViewModel,负责表单、附件上传和提交任务。 +@MainActor +final class TaskCreateViewModel { + var onChange: (() -> Void)? + var taskName = "" { didSet { onChange?() } } + var remark = "" { didSet { onChange?() } } + var urgentHourText = "0" { didSet { onChange?() } } + var selectedOrder: AvailableOrderResponse? { didSet { onChange?() } } + var availableOrders: [AvailableOrderResponse] = [] { didSet { onChange?() } } + var selectedCloudFiles: [TaskCloudSelectionItem] = [] { didSet { onChange?() } } + var selectedLocalFiles: [TaskLocalUploadItem] = [] { didSet { onChange?() } } + var isLoadingOrders = false { didSet { onChange?() } } + var isSubmitting = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + var didSubmitSuccessfully = false { didSet { onChange?() } } + + /// 判断当前表单是否满足提交按钮启用条件。 + var canSubmit: Bool { + validateForm(scenicId: 1, shouldSetError: false) + && !isSubmitting + && !selectedLocalFiles.contains(where: \.isUploading) + } + + /// 加载可关联订单列表。 + func loadAvailableOrders(api: any TaskServing, scenicId: Int?) async { + guard let scenicId else { + availableOrders = [] + return + } + isLoadingOrders = true + defer { isLoadingOrders = false } + + do { + availableOrders = try await api.availableOrderList(scenicId: scenicId) + } catch { + availableOrders = [] + errorMessage = error.localizedDescription + } + } + + /// 选择或清除关联订单。 + func selectOrder(_ order: AvailableOrderResponse?) { + selectedOrder = order + } + + /// 添加云盘附件,按文件 ID 去重。 + func mergeCloudFiles(_ files: [TaskCloudSelectionItem]) { + var next = selectedCloudFiles + for file in files where !next.contains(where: { $0.id == file.id }) { + next.append(file) + } + selectedCloudFiles = next + } + + /// 移除指定云盘附件。 + func removeCloudFile(id: Int) { + selectedCloudFiles.removeAll { $0.id == id } + } + + /// 添加本地附件数据。 + func addLocalFile(data: Data, fileName: String) { + let fileType = Self.fileType(for: fileName) + selectedLocalFiles.append( + TaskLocalUploadItem( + id: UUID(), + data: data, + fileName: fileName, + fileType: fileType, + remark: "", + uploadedURL: nil, + progress: 0, + errorMessage: nil + ) + ) + } + + /// 移除指定本地附件。 + func removeLocalFile(id: UUID) { + selectedLocalFiles.removeAll { $0.id == id } + } + + /// 提交任务;本地附件会先上传 OSS,成功后再提交最终 URL。 + @discardableResult + func submit(api: any TaskServing, uploadService: any OSSUploadServing, scenicId: Int?) async throws -> Bool { + guard !isSubmitting else { return false } + guard validateForm(scenicId: scenicId, shouldSetError: true) else { return false } + guard let scenicId, let urgentHour = Int(urgentHourText.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } + + isSubmitting = true + errorMessage = nil + didSubmitSuccessfully = false + defer { isSubmitting = false } + + do { + let uploadedFiles = try await uploadLocalFiles(uploadService: uploadService, scenicId: scenicId) + let request = AddTaskRequest( + scenicId: "\(scenicId)", + name: taskName.trimmingCharacters(in: .whitespacesAndNewlines), + orderNumber: selectedOrder?.orderNumber, + remark: remark.trimmingCharacters(in: .whitespacesAndNewlines), + urgentHour: urgentHour, + cloudFile: selectedCloudFiles.map { CloudFileItem(fileId: $0.id, remark: $0.remark) }, + uploadFile: uploadedFiles + ) + try await api.addTask(request) + didSubmitSuccessfully = true + resetForm() + return true + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + /// 清空发布任务表单。 + func resetForm() { + taskName = "" + remark = "" + urgentHourText = "0" + selectedOrder = nil + selectedCloudFiles = [] + selectedLocalFiles = [] + } + + /// 校验表单并按需写入错误提示。 + private func validateForm(scenicId: Int?, shouldSetError: Bool) -> Bool { + func fail(_ message: String) -> Bool { + if shouldSetError { errorMessage = message } + return false + } + + guard scenicId != nil else { + return fail("请先选择景区") + } + guard !taskName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return fail("请输入任务名称") + } + guard !remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return fail("请输入任务备注") + } + guard let urgentHour = Int(urgentHourText.trimmingCharacters(in: .whitespacesAndNewlines)), urgentHour >= 0 else { + return fail("请输入正确的紧急小时") + } + guard !selectedLocalFiles.contains(where: \.isUploading) else { + return fail("文件上传中,请稍后提交") + } + return true + } + + /// 上传所有尚未上传的本地附件并返回提交请求需要的文件列表。 + private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [UploadFileItem] { + var uploadedFiles: [UploadFileItem] = [] + for index in selectedLocalFiles.indices { + if let uploadedURL = selectedLocalFiles[index].uploadedURL { + uploadedFiles.append( + UploadFileItem( + fileUrl: uploadedURL, + fileName: selectedLocalFiles[index].fileName, + remark: selectedLocalFiles[index].remark + ) + ) + continue + } + + selectedLocalFiles[index].errorMessage = nil + let fileID = selectedLocalFiles[index].id + do { + let url = try await uploadService.uploadTaskFile( + data: selectedLocalFiles[index].data, + fileName: selectedLocalFiles[index].fileName, + fileType: selectedLocalFiles[index].fileType, + scenicId: scenicId, + onProgress: { [weak self] progress in + Task { @MainActor in + self?.updateLocalFileProgress(id: fileID, progress: progress) + } + } + ) + selectedLocalFiles[index].uploadedURL = url + selectedLocalFiles[index].progress = 100 + uploadedFiles.append( + UploadFileItem( + fileUrl: url, + fileName: selectedLocalFiles[index].fileName, + remark: selectedLocalFiles[index].remark + ) + ) + } catch { + selectedLocalFiles[index].progress = 0 + selectedLocalFiles[index].errorMessage = error.localizedDescription + throw error + } + } + return uploadedFiles + } + + /// 更新指定本地附件的上传进度。 + private func updateLocalFileProgress(id: UUID, progress: Int) { + guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return } + selectedLocalFiles[index].progress = progress + } + + /// 根据文件名推断后端需要的文件类型。 + private static func fileType(for fileName: String) -> Int { + let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased() + return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2 + } +} diff --git a/suixinkan_ios/Features/Tasks/ViewModels/TaskManagementViewModel.swift b/suixinkan_ios/Features/Tasks/ViewModels/TaskManagementViewModel.swift new file mode 100644 index 0000000..5325646 --- /dev/null +++ b/suixinkan_ios/Features/Tasks/ViewModels/TaskManagementViewModel.swift @@ -0,0 +1,166 @@ +// +// TaskManagementViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation + +/// 任务状态筛选实体,表示任务管理页支持的任务状态。 +enum TaskStatusFilter: Int, CaseIterable, Identifiable { + case all = 0 + case unaccepted = 1 + case accepted = 2 + case completed = 3 + case rejected = 4 + + var id: Int { rawValue } + + /// 返回状态筛选在页面上的展示文案。 + var title: String { + switch self { + case .all: + "全部" + case .unaccepted: + "待接收" + case .accepted: + "处理中" + case .completed: + "已完成" + case .rejected: + "已驳回" + } + } +} + +/// 任务管理 ViewModel,负责任务列表、筛选、分页和错误状态。 +@MainActor +final class TaskManagementViewModel { + var onChange: (() -> Void)? + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var tasks: [PhotographerTaskItem] = [] { didSet { onChange?() } } + var total = 0 { didSet { onChange?() } } + var page = 1 { didSet { onChange?() } } + var selectedStatus: TaskStatusFilter = .all { didSet { onChange?() } } + var searchText = "" { didSet { onChange?() } } + var startDate: Date? { didSet { onChange?() } } + var endDate: Date? { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 10 + + /// 判断任务列表是否还有下一页。 + var hasMore: Bool { + tasks.count < total + } + + /// 首屏加载任务列表;无景区时清空旧数据且不请求接口。 + func reload(api: any TaskServing, scenicId: Int?) async throws { + guard let scenicId else { + resetList() + errorMessage = "请先选择景区" + return + } + + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let payload = try await requestList(api: api, scenicId: scenicId, page: 1) + page = 1 + tasks = payload.list + total = payload.total + } catch { + resetList() + errorMessage = error.localizedDescription + throw error + } + } + + /// 加载下一页任务列表;最后一页或正在加载时不会重复请求。 + func loadMore(api: any TaskServing, scenicId: Int?) async throws { + guard let scenicId, hasMore, !isLoadingMore else { return } + isLoadingMore = true + errorMessage = nil + defer { isLoadingMore = false } + + let nextPage = page + 1 + do { + let payload = try await requestList(api: api, scenicId: scenicId, page: nextPage) + page = nextPage + total = payload.total + tasks.append(contentsOf: payload.list) + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + /// 清空任务列表和分页状态。 + func resetList() { + tasks = [] + total = 0 + page = 1 + isLoading = false + isLoadingMore = false + } + + /// 组装并发送任务列表请求。 + private func requestList(api: any TaskServing, scenicId: Int, page: Int) async throws -> ListPayload { + try await api.taskList( + scenicId: scenicId, + page: page, + pageSize: pageSize, + taskStatus: selectedStatus.rawValue, + taskName: trimmedSearchText, + startTime: Self.requestDateString(from: startDate), + endTime: Self.requestDateString(from: endDate) + ) + } + + /// 搜索关键字去除空白后为空则不传给后端。 + private var trimmedSearchText: String? { + let text = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text + } + + /// 日期请求格式化器。 + private static let requestDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + /// 将可选日期格式化成请求字符串。 + private static func requestDateString(from date: Date?) -> String? { + guard let date else { return nil } + return requestDateFormatter.string(from: date) + } +} + +/// 任务详情 ViewModel,负责详情加载和列表摘要兜底展示。 +@MainActor +final class TaskDetailViewModel { + var onChange: (() -> Void)? + var isLoading = false { didSet { onChange?() } } + var detail: TaskDetailResponse? { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 加载任务详情;接口失败时保留外部传入的列表摘要。 + func load(api: any TaskServing, taskId: Int) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + detail = try await api.taskDetail(id: taskId) + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/suixinkan_ios/Features/Wallet/API/WalletAPI.swift b/suixinkan_ios/Features/Wallet/API/WalletAPI.swift new file mode 100644 index 0000000..a5e68a2 --- /dev/null +++ b/suixinkan_ios/Features/Wallet/API/WalletAPI.swift @@ -0,0 +1,202 @@ +// +// WalletAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 钱包模块服务协议,定义钱包、提现、银行卡和积分接口能力。 +@MainActor +protocol WalletServing { + /// 获取钱包汇总数据。 + func walletSummary(type: Int) async throws -> WalletSummaryResponse + + /// 获取钱包收益明细分页。 + func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse + + /// 获取提现记录分页。 + func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse + + /// 获取提现申请所需信息。 + func withdrawInfo() async throws -> WithdrawInfoResponse + + /// 发送提现短信验证码。 + func withdrawSendSms() async throws + + /// 提交提现申请。 + func withdrawApply(amount: String, smsCode: String) async throws + + /// 获取银行卡审核和资料信息。 + func bankCardInfo() async throws -> BankCardInfoResponse + + /// 获取银行列表。 + func bankList() async throws -> BankListResponse + + /// 获取省市区列表。 + func areas() async throws -> [AreaNode] + + /// 发送银行卡设置短信验证码。 + func bankCardVerifyCode() async throws + + /// 更新银行卡资料。 + func updateBankInfo(_ request: UpdateBankInfoRequest) async throws + + /// 获取积分概览。 + func pointOverview(staffId: Int) async throws -> PointOverviewResponse + + /// 提交积分兑换申请。 + func pointWithdrawApply(points: Int, remark: String) async throws + + /// 获取积分兑换记录。 + func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse +} + +@MainActor +/// 钱包 API,封装个人钱包、提现、银行卡和积分兑换网络请求。 +final class WalletAPI { + private let client: APIClient + + /// 初始化钱包 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取钱包汇总数据。 + func walletSummary(type: Int = 0) async throws -> WalletSummaryResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/wallet/summary", + queryItems: [URLQueryItem(name: "type", value: String(type))] + ) + ) + } + + /// 获取钱包收益明细分页。 + func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/wallet/earning-detail", + queryItems: [ + URLQueryItem(name: "start_date", value: startDate), + URLQueryItem(name: "end_date", value: endDate), + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)) + ] + ) + ) + } + + /// 获取提现记录分页。 + func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/wallet/withdraw-list", + queryItems: [ + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "page_size", value: String(pageSize)) + ] + ) + ) + } + + /// 获取提现申请所需信息。 + func withdrawInfo() async throws -> WithdrawInfoResponse { + try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/withdraw-info") + ) + } + + /// 发送提现短信验证码。 + func withdrawSendSms() async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/withdraw-send-sms", body: EmptyPayload()) + ) + } + + /// 提交提现申请。 + func withdrawApply(amount: String, smsCode: String) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/wallet/withdraw-apply", + body: WithdrawApplyRequest(amount: amount, smsCode: smsCode) + ) + ) + } + + /// 获取银行卡审核和资料信息。 + func bankCardInfo() async throws -> BankCardInfoResponse { + try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-card/info") + ) + } + + /// 获取银行列表。 + func bankList() async throws -> BankListResponse { + try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/wallet/bank-list") + ) + } + + /// 获取省市区列表。 + func areas() async throws -> [AreaNode] { + try await client.send( + APIRequest(method: .get, path: "/api/app/config/areas") + ) + } + + /// 发送银行卡设置短信验证码。 + func bankCardVerifyCode() async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/bank-card/sms-verify-code", body: EmptyPayload()) + ) + } + + /// 更新银行卡资料。 + func updateBankInfo(_ request: UpdateBankInfoRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/wallet/bank-card/update", body: request) + ) + } + + /// 获取积分概览。 + func pointOverview(staffId: Int) async throws -> PointOverviewResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/point/overview", + queryItems: [URLQueryItem(name: "staff_id", value: String(staffId))], + body: EmptyPayload() + ) + ) + } + + /// 提交积分兑换申请。 + func pointWithdrawApply(points: Int, remark: String = "积分提现申请") async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/point/withdraw/apply", + body: PointWithdrawApplyRequest(points: points, remark: remark) + ) + ) + } + + /// 获取积分兑换记录。 + func pointWithdrawList(status: Int? = nil, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/point/withdraw/list", + body: PointWithdrawListRequest(status: status, page: page, pageSize: pageSize) + ) + ) + } +} + +extension WalletAPI: WalletServing {} diff --git a/suixinkan_ios/Features/Wallet/Models/WalletModels.swift b/suixinkan_ios/Features/Wallet/Models/WalletModels.swift new file mode 100644 index 0000000..523052d --- /dev/null +++ b/suixinkan_ios/Features/Wallet/Models/WalletModels.swift @@ -0,0 +1,627 @@ +// +// WalletModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +/// 钱包汇总响应实体,表示累计收益、当前余额和可提现金额。 +struct WalletSummaryResponse: Decodable, Equatable { + let amountTotal: String + let amountCurrentBalance: String + let amountWithdrawable: String + + enum CodingKeys: String, CodingKey { + case amountTotal = "amount_total" + case amountCurrentBalance = "amount_current_balance" + case amountWithdrawable = "amount_withdrawable" + } + + /// 创建钱包汇总响应实体,主要用于测试和空状态。 + init(amountTotal: String = "0.00", amountCurrentBalance: String = "0.00", amountWithdrawable: String = "0.00") { + self.amountTotal = amountTotal + self.amountCurrentBalance = amountCurrentBalance + self.amountWithdrawable = amountWithdrawable + } + + /// 自定义解码,兼容金额字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + amountTotal = try container.decodeLossyString(forKey: .amountTotal) + amountCurrentBalance = try container.decodeLossyString(forKey: .amountCurrentBalance) + amountWithdrawable = try container.decodeLossyString(forKey: .amountWithdrawable) + } +} + +/// 钱包收益明细响应实体,表示日分组收益和分页总数。 +struct WalletEarningDetailResponse: Decodable, Equatable { + let totalAmount: String + let totalPoints: Int + let total: Int + let list: [WalletEarningDetailGroup] + + enum CodingKeys: String, CodingKey { + case totalAmount = "total_amount" + case totalPoints = "total_points" + case total + case list + } + + /// 创建收益明细响应实体,主要用于测试替身。 + init(totalAmount: String = "0.00", totalPoints: Int = 0, total: Int = 0, list: [WalletEarningDetailGroup] = []) { + self.totalAmount = totalAmount + self.totalPoints = totalPoints + self.total = total + self.list = list + } + + /// 自定义解码,兼容后端字段缺失和类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + totalAmount = try container.decodeLossyString(forKey: .totalAmount) + totalPoints = try container.decodeLossyInt(forKey: .totalPoints) ?? 0 + total = try container.decodeLossyInt(forKey: .total) ?? 0 + list = try container.decodeIfPresent([WalletEarningDetailGroup].self, forKey: .list) ?? [] + } +} + +/// 钱包收益日分组实体,表示某一天的收益、积分和明细。 +struct WalletEarningDetailGroup: Decodable, Equatable, Identifiable { + let date: String + let dayAmount: String + let dayPoints: Int + let items: [WalletEarningDetailItem] + + var id: String { date } + + enum CodingKeys: String, CodingKey { + case date + case dayAmount = "day_amount" + case dayPoints = "day_points" + case items + } + + /// 创建收益日分组实体,主要用于测试替身。 + init(date: String, dayAmount: String = "0.00", dayPoints: Int = 0, items: [WalletEarningDetailItem] = []) { + self.date = date + self.dayAmount = dayAmount + self.dayPoints = dayPoints + self.items = items + } + + /// 自定义解码,兼容后端字段缺失和类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decodeLossyString(forKey: .date) + dayAmount = try container.decodeLossyString(forKey: .dayAmount) + dayPoints = try container.decodeLossyInt(forKey: .dayPoints) ?? 0 + items = try container.decodeIfPresent([WalletEarningDetailItem].self, forKey: .items) ?? [] + } +} + +/// 钱包收益明细实体,表示一笔订单收益或积分来源。 +struct WalletEarningDetailItem: Decodable, Equatable, Identifiable { + let id: Int64 + let amount: String + let points: Int + let type: Int + let typeLabel: String + let orderNumberSuffix: String + let createdAt: String + let withdrawLabel: String? + let source: String? + + enum CodingKeys: String, CodingKey { + case id + case amount + case points + case type + case typeLabel = "type_label" + case orderNumberSuffix = "order_number_suffix" + case createdAt = "created_at" + case withdrawLabel = "withdraw_label" + case source + } + + /// 创建收益明细实体,主要用于测试替身和本地空状态。 + init( + id: Int64 = 0, + amount: String = "", + points: Int = 0, + type: Int = 0, + typeLabel: String = "", + orderNumberSuffix: String = "", + createdAt: String = "", + withdrawLabel: String? = nil, + source: String? = nil + ) { + self.id = id + self.amount = amount + self.points = points + self.type = type + self.typeLabel = typeLabel + self.orderNumberSuffix = orderNumberSuffix + self.createdAt = createdAt + self.withdrawLabel = withdrawLabel + self.source = source + } + + /// 自定义解码,兼容字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) + amount = try container.decodeLossyString(forKey: .amount) + points = try container.decodeLossyInt(forKey: .points) ?? 0 + type = try container.decodeLossyInt(forKey: .type) ?? 0 + typeLabel = try container.decodeLossyString(forKey: .typeLabel) + orderNumberSuffix = try container.decodeLossyString(forKey: .orderNumberSuffix) + createdAt = try container.decodeLossyString(forKey: .createdAt) + withdrawLabel = try container.decodeIfPresent(String.self, forKey: .withdrawLabel) + source = try container.decodeIfPresent(String.self, forKey: .source) + } +} + +/// 钱包提现记录响应实体,表示提现分页列表。 +struct WalletWithdrawListResponse: Decodable, Equatable { + let total: Int + let item: [WalletWithdrawRecord] + + enum CodingKeys: String, CodingKey { + case total + case item + } + + /// 创建提现记录响应实体,主要用于测试替身。 + init(total: Int = 0, item: [WalletWithdrawRecord] = []) { + self.total = total + self.item = item + } + + /// 自定义解码,兼容 total 字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + item = try container.decodeIfPresent([WalletWithdrawRecord].self, forKey: .item) ?? [] + } +} + +/// 钱包提现记录实体,表示一笔提现申请。 +struct WalletWithdrawRecord: Decodable, Equatable, Identifiable { + let id: Int64 + let amount: String + let createdAt: String + let statusLabel: String + let expectedAt: String? + let auditTime: String? + let completedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case amount + case createdAt = "created_at" + case statusLabel = "status_label" + case expectedAt = "expected_at" + case auditTime = "audit_time" + case completedAt = "completed_at" + } + + /// 自定义解码,兼容字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = Int64(try container.decodeLossyInt(forKey: .id) ?? 0) + amount = try container.decodeLossyString(forKey: .amount) + createdAt = try container.decodeLossyString(forKey: .createdAt) + statusLabel = try container.decodeLossyString(forKey: .statusLabel) + expectedAt = try container.decodeIfPresent(String.self, forKey: .expectedAt) + auditTime = try container.decodeIfPresent(String.self, forKey: .auditTime) + completedAt = try container.decodeIfPresent(String.self, forKey: .completedAt) + } +} + +/// 银行卡信息响应实体,包裹当前银行卡资料。 +struct BankCardInfoResponse: Decodable, Equatable { + let bankCard: WalletBankCardInfo? + + enum CodingKeys: String, CodingKey { + case bankCard = "bank_card" + } +} + +/// 钱包银行卡实体,表示银行卡资料和审核状态。 +struct WalletBankCardInfo: Decodable, Equatable, Identifiable { + var id: String { cardNumber } + let realName: String + let bankName: String + let branchName: String + let cardNumber: String + let frontUrl: String? + let backUrl: String? + let provinceCode: String? + let cityCode: String? + let rejectReason: String? + let auditStatus: Int + let auditStatusLabel: String + + enum CodingKeys: String, CodingKey { + case realName = "real_name" + case bankName = "bank_name" + case branchName = "branch_name" + case cardNumber = "card_number" + case frontUrl = "front_url" + case backUrl = "back_url" + case provinceCode = "province_code" + case cityCode = "city_code" + case rejectReason = "reject_reason" + case auditStatus = "audit_status" + case auditStatusLabel = "audit_status_label" + } + + /// 自定义解码,兼容审核状态字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + realName = try container.decodeLossyString(forKey: .realName) + bankName = try container.decodeLossyString(forKey: .bankName) + branchName = try container.decodeLossyString(forKey: .branchName) + cardNumber = try container.decodeLossyString(forKey: .cardNumber) + frontUrl = try container.decodeIfPresent(String.self, forKey: .frontUrl) + backUrl = try container.decodeIfPresent(String.self, forKey: .backUrl) + provinceCode = try container.decodeIfPresent(String.self, forKey: .provinceCode) + cityCode = try container.decodeIfPresent(String.self, forKey: .cityCode) + rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason) + auditStatus = try container.decodeLossyInt(forKey: .auditStatus) ?? 0 + auditStatusLabel = try container.decodeLossyString(forKey: .auditStatusLabel) + } +} + +/// 银行列表响应实体,表示可选择银行名称。 +struct BankListResponse: Decodable, Equatable { + let banks: [String] + + enum CodingKeys: String, CodingKey { + case banks + } + + /// 创建银行列表响应实体,主要用于测试替身。 + init(banks: [String] = []) { + self.banks = banks + } +} + +/// 省市区节点实体,表示提现银行卡设置中的地区树。 +struct AreaNode: Decodable, Equatable, Identifiable { + let id: String + let code: String + let name: String + let children: [AreaNode] + + enum CodingKeys: String, CodingKey { + case id + case code + case name + case children + } + + /// 自定义解码,兼容 id/code 类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try container.decodeLossyString(forKey: .code) + let rawId = try container.decodeLossyString(forKey: .id).trimmingCharacters(in: .whitespacesAndNewlines) + id = rawId.isEmpty ? code : rawId + name = try container.decodeLossyString(forKey: .name) + children = try container.decodeIfPresent([AreaNode].self, forKey: .children) ?? [] + } +} + +/// 提现信息响应实体,表示提现上限、手机号和银行卡摘要。 +struct WithdrawInfoResponse: Decodable, Equatable { + let amountWithdrawable: String + let minWithdrawAmount: String + let maxSingleWithdrawAmount: String + let maxDailyWithdrawAmount: String + let userPhone: String + let bankCard: WithdrawBankCardInfo + let withdrawInfo: [String] + + enum CodingKeys: String, CodingKey { + case amountWithdrawable = "amount_withdrawable" + case minWithdrawAmount = "min_withdraw_amount" + case maxSingleWithdrawAmount = "max_single_withdraw_amount" + case maxDailyWithdrawAmount = "max_daily_withdraw_amount" + case userPhone = "user_phone" + case bankCard = "bank_card" + case withdrawInfo = "withdraw_info" + } + + /// 自定义解码,兼容金额字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + amountWithdrawable = try container.decodeLossyString(forKey: .amountWithdrawable) + minWithdrawAmount = try container.decodeLossyString(forKey: .minWithdrawAmount) + maxSingleWithdrawAmount = try container.decodeLossyString(forKey: .maxSingleWithdrawAmount) + maxDailyWithdrawAmount = try container.decodeLossyString(forKey: .maxDailyWithdrawAmount) + userPhone = try container.decodeLossyString(forKey: .userPhone) + bankCard = try container.decodeIfPresent(WithdrawBankCardInfo.self, forKey: .bankCard) ?? WithdrawBankCardInfo() + withdrawInfo = try container.decodeIfPresent([String].self, forKey: .withdrawInfo) ?? [] + } +} + +/// 提现银行卡摘要实体,表示提现页面展示的银行卡信息。 +struct WithdrawBankCardInfo: Decodable, Equatable { + let realName: String + let bankName: String + let cardNumber: String + + enum CodingKeys: String, CodingKey { + case realName = "real_name" + case bankName = "bank_name" + case cardNumber = "card_number" + } + + /// 创建提现银行卡摘要,主要用于空状态兜底。 + init(realName: String = "", bankName: String = "", cardNumber: String = "") { + self.realName = realName + self.bankName = bankName + self.cardNumber = cardNumber + } + + /// 自定义解码,兼容字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + realName = try container.decodeLossyString(forKey: .realName) + bankName = try container.decodeLossyString(forKey: .bankName) + cardNumber = try container.decodeLossyString(forKey: .cardNumber) + } +} + +/// 提现申请请求实体,表示金额和短信验证码。 +struct WithdrawApplyRequest: Encodable { + let amount: String + let smsCode: String + + enum CodingKeys: String, CodingKey { + case amount + case smsCode = "sms_code" + } +} + +/// 银行卡更新请求实体,表示银行卡资料、证件图片 URL 和验证码。 +struct UpdateBankInfoRequest: Encodable, Equatable { + let realName: String + let cardNumber: String + let bankName: String + let branchName: String + let frontUrl: String + let backUrl: String + let provinceCode: String + let cityCode: String + let smsVerifyCode: String + + enum CodingKeys: String, CodingKey { + case realName = "real_name" + case cardNumber = "card_number" + case bankName = "bank_name" + case branchName = "branch_name" + case frontUrl = "front_url" + case backUrl = "back_url" + case provinceCode = "province_code" + case cityCode = "city_code" + case smsVerifyCode = "sms_verify_code" + } +} + +/// 积分概览响应实体,表示当前积分可兑换状态。 +struct PointOverviewResponse: Decodable, Equatable { + let totalPoints: Int + let availablePoints: Int + let withdrawnPoints: Int + let pendingPoints: Int + let time: String + + enum CodingKeys: String, CodingKey { + case totalPoints = "total_points" + case availablePoints = "available_points" + case withdrawnPoints = "withdrawn_points" + case pendingPoints = "pending_points" + case time + } + + /// 创建积分概览响应实体,主要用于空状态和测试替身。 + init(totalPoints: Int = 0, availablePoints: Int = 0, withdrawnPoints: Int = 0, pendingPoints: Int = 0, time: String = "") { + self.totalPoints = totalPoints + self.availablePoints = availablePoints + self.withdrawnPoints = withdrawnPoints + self.pendingPoints = pendingPoints + self.time = time + } + + /// 自定义解码,兼容积分字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + totalPoints = try container.decodeLossyInt(forKey: .totalPoints) ?? 0 + availablePoints = try container.decodeLossyInt(forKey: .availablePoints) ?? 0 + withdrawnPoints = try container.decodeLossyInt(forKey: .withdrawnPoints) ?? 0 + pendingPoints = try container.decodeLossyInt(forKey: .pendingPoints) ?? 0 + time = try container.decodeLossyString(forKey: .time) + } +} + +/// 积分兑换申请请求实体。 +struct PointWithdrawApplyRequest: Encodable { + let points: Int + let remark: String +} + +/// 积分兑换列表请求实体。 +struct PointWithdrawListRequest: Encodable { + let status: Int? + let page: Int + let pageSize: Int + + enum CodingKeys: String, CodingKey { + case status + case page + case pageSize = "page_size" + } +} + +/// 积分兑换列表响应实体。 +struct PointWithdrawListResponse: Decodable, Equatable { + let total: Int + let list: [PointWithdrawItem] + + enum CodingKeys: String, CodingKey { + case total + case list + } + + /// 创建积分兑换列表响应实体,主要用于测试替身。 + init(total: Int = 0, list: [PointWithdrawItem] = []) { + self.total = total + self.list = list + } + + /// 自定义解码,兼容字段缺失和类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = try container.decodeLossyInt(forKey: .total) ?? 0 + list = try container.decodeIfPresent([PointWithdrawItem].self, forKey: .list) ?? [] + } +} + +/// 积分兑换记录实体。 +struct PointWithdrawItem: Decodable, Equatable, Identifiable { + let id: Int + let points: Int + let amount: Double + let status: Int + let createdAt: String + + enum CodingKeys: String, CodingKey { + case id + case points + case amount + case status + case createdAt = "created_at" + } + + /// 自定义解码,兼容字段类型不稳定。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + points = try container.decodeLossyInt(forKey: .points) ?? 0 + amount = try container.decodeLossyDouble(forKey: .amount) ?? 0 + status = try container.decodeLossyInt(forKey: .status) ?? 0 + createdAt = try container.decodeLossyString(forKey: .createdAt) + } +} + +/// 钱包本地路由实体,表示钱包内二级页面。 +enum WalletRoute: Hashable, Identifiable { + case withdrawApply + case bankCardSettings + case pointsRedemption + case realNameAuth + + var id: String { + switch self { + case .withdrawApply: "withdrawApply" + case .bankCardSettings: "bankCardSettings" + case .pointsRedemption: "pointsRedemption" + case .realNameAuth: "realNameAuth" + } + } +} + +/// 提现资格决策实体,表示点击提现后的下一步页面或提示。 +enum WalletWithdrawDecision: Equatable { + case route(WalletRoute) + case message(String) +} + +/// 钱包筛选区间实体,表示收益明细查询时间范围。 +enum WalletDateFilter: String, CaseIterable, Identifiable { + case last7 + case last30 + case last180 + + var id: String { rawValue } + + /// 当前筛选区间的展示标题。 + var title: String { + switch self { + case .last7: "近7日" + case .last30: "近30日" + case .last180: "近180日" + } + } + + /// 根据当前日期计算开始和结束日期。 + func range(today: Date = Date(), calendar: Calendar = .current) -> (start: String, end: String) { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.dateFormat = "yyyy-MM-dd" + let days: Int + switch self { + case .last7: + days = 6 + case .last30: + days = 29 + case .last180: + days = 179 + } + let startDate = calendar.date(byAdding: .day, value: -days, to: today) ?? today + return (formatter.string(from: startDate), formatter.string(from: today)) + } +} + +private extension KeyedDecodingContainer { + /// 将字符串、数字或空值宽松解码为字符串。 + 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 value.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(value)) : String(value) + } + return "" + } + + /// 将字符串或数字宽松解码为整数。 + 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 + } + + /// 将字符串或数字宽松解码为小数。 + func decodeLossyDouble(forKey key: Key) throws -> Double? { + if let value = try decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try decodeIfPresent(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} diff --git a/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift b/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift new file mode 100644 index 0000000..e39b9a1 --- /dev/null +++ b/suixinkan_ios/Features/Wallet/ViewControllers/WalletViewControllers.swift @@ -0,0 +1,132 @@ +// +// WalletViewControllers.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 钱包首页。 +final class WalletViewController: ModuleTableViewController { + private let viewModel = WalletViewModel() + private let summaryLabel = UILabel() + + override func viewDidLoad() { + title = "我的钱包" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "提现", + style: .plain, + target: self, + action: #selector(openWithdraw) + ) + super.viewDidLoad() + setupHeader() + wireViewModel(viewModel) { [weak self] in self?.updateSummary() } + } + + private func setupHeader() { + summaryLabel.numberOfLines = 0 + summaryLabel.font = .systemFont(ofSize: 14) + summaryLabel.textAlignment = .center + summaryLabel.textColor = AppDesign.textSecondary + summaryLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 72) + tableView.tableHeaderView = summaryLabel + } + + override func tableRowCount() -> Int { + switch viewModel.selectedTab { + case .earnings: + return viewModel.earningsGroups.flatMap(\.items).count + case .withdraws: + return viewModel.withdrawRecords.count + } + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + switch viewModel.selectedTab { + case .earnings: + let items = viewModel.earningsGroups.flatMap(\.items) + let item = items[indexPath.row] + cell.configure(title: item.typeLabel, subtitle: item.amount, detail: item.createdAt) + case .withdraws: + let record = viewModel.withdrawRecords[indexPath.row] + cell.configure(title: "¥\(record.amount)", subtitle: record.statusLabel, detail: record.createdAt) + } + } + + override func reloadContent() async { + await viewModel.loadInitial(api: services.walletAPI, staffId: services.staffId) + updateSummary() + } + + private func updateSummary() { + summaryLabel.text = "\(viewModel.withdrawableText) · \(viewModel.totalAmountText)" + navigationItem.rightBarButtonItem?.title = viewModel.selectedTab == .earnings ? "提现记录" : "收益明细" + } + + @objc private func openWithdraw() { + navigationController?.pushViewController(WalletWithdrawViewController(), animated: true) + } +} + +extension WalletViewModel: ViewModelBindable {} + +/// 钱包提现页。 +final class WalletWithdrawViewController: ModuleTableViewController { + private let viewModel = WithdrawApplyViewModel() + private let amountField = UITextField() + private let smsField = UITextField() + + override func viewDidLoad() { + title = "申请提现" + navigationItem.rightBarButtonItems = [ + UIBarButtonItem(title: "提交", style: .done, target: self, action: #selector(submit)), + UIBarButtonItem(title: "验证码", style: .plain, target: self, action: #selector(sendSms)) + ] + super.viewDidLoad() + amountField.placeholder = "提现金额" + amountField.borderStyle = .roundedRect + amountField.keyboardType = .decimalPad + smsField.placeholder = "短信验证码" + smsField.borderStyle = .roundedRect + let stack = UIStackView(arrangedSubviews: [amountField, smsField]) + stack.axis = .vertical + stack.spacing = 8 + stack.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 96) + stack.layoutMargins = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16) + stack.isLayoutMarginsRelativeArrangement = true + tableView.tableHeaderView = stack + wireViewModel(viewModel) { } + } + + override func tableRowCount() -> Int { 1 } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let amount = viewModel.info?.amountWithdrawable ?? "0" + cell.configure(title: "可提现", subtitle: "¥ \(amount)") + } + + override func reloadContent() async { + await viewModel.load(api: services.walletAPI) + } + + @objc private func sendSms() { + Task { await viewModel.sendSms(api: services.walletAPI) } + } + + @objc private func submit() { + viewModel.amountText = amountField.text ?? "" + viewModel.smsCode = smsField.text ?? "" + Task { + if await viewModel.submit(api: services.walletAPI) { + services.toastCenter.show("提现申请已提交") + navigationController?.popViewController(animated: true) + } else if let message = viewModel.errorMessage { + services.toastCenter.show(message) + } + } + } +} + +extension WithdrawApplyViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/Wallet/ViewModels/WalletViewModels.swift b/suixinkan_ios/Features/Wallet/ViewModels/WalletViewModels.swift new file mode 100644 index 0000000..df04d46 --- /dev/null +++ b/suixinkan_ios/Features/Wallet/ViewModels/WalletViewModels.swift @@ -0,0 +1,537 @@ +// +// WalletViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/22. +// + +import Foundation + +@MainActor +/// 钱包实名认证服务协议,抽象实名认证状态读取能力以便测试替换。 +protocol WalletRealNameServing { + /// 获取当前登录用户实名认证状态。 + func realNameInfo() async throws -> RealNameInfoResponse +} + +extension ProfileAPI: WalletRealNameServing {} + +/// 钱包首页明细 Tab,区分收益明细和提现记录。 +enum WalletLedgerTab: String, CaseIterable, Identifiable { + case earnings + case withdraws + + var id: String { rawValue } + + /// Tab 展示标题。 + var title: String { + switch self { + case .earnings: "收益明细" + case .withdraws: "提现记录" + } + } +} + +@MainActor +/// 钱包首页 ViewModel,管理钱包汇总、收益明细、提现记录和提现资格流。 +final class WalletViewModel { + var onChange: (() -> Void)? + var summary: WalletSummaryResponse? { didSet { onChange?() } } + var pointsOverview: PointOverviewResponse? { didSet { onChange?() } } + var selectedTab: WalletLedgerTab = .earnings { didSet { onChange?() } } + var selectedFilter: WalletDateFilter = .last30 { didSet { onChange?() } } + var earningsGroups: [WalletEarningDetailGroup] = [] { didSet { onChange?() } } + var withdrawRecords: [WalletWithdrawRecord] = [] { didSet { onChange?() } } + var isLoadingSummary = false { didSet { onChange?() } } + var isLoadingList = false { didSet { onChange?() } } + var isCheckingWithdraw = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 20 + private var earningsPage = 1 + private var earningsTotal = 0 + private var withdrawPage = 1 + private var withdrawTotal = 0 + + /// 钱包可提现金额展示文本。 + var withdrawableText: String { + "¥ \(Self.moneyText(summary?.amountWithdrawable))" + } + + /// 钱包累计金额展示文本。 + var totalAmountText: String { + "¥ \(Self.moneyText(summary?.amountTotal))" + } + + /// 当前余额展示文本。 + var currentBalanceText: String { + "¥ \(Self.moneyText(summary?.amountCurrentBalance))" + } + + /// 是否还能继续加载收益明细。 + var canLoadMoreEarnings: Bool { + earningsGroups.flatMap(\.items).count < earningsTotal + } + + /// 是否还能继续加载提现记录。 + var canLoadMoreWithdraws: Bool { + withdrawRecords.count < withdrawTotal + } + + /// 首次加载钱包首页数据。 + func loadInitial(api: WalletServing, staffId: Int?) async { + isLoadingSummary = true + errorMessage = nil + async let summaryResult = api.walletSummary(type: 0) + async let pointsResult: PointOverviewResponse? = { + guard let staffId else { return nil } + return try? await api.pointOverview(staffId: staffId) + }() + + do { + summary = try await summaryResult + pointsOverview = await pointsResult + } catch { + errorMessage = error.localizedDescription + } + isLoadingSummary = false + await reloadList(api: api) + } + + /// 按当前 Tab 和筛选条件刷新列表。 + func reloadList(api: WalletServing) async { + switch selectedTab { + case .earnings: + earningsPage = 1 + await loadEarnings(api: api, reset: true) + case .withdraws: + withdrawPage = 1 + await loadWithdraws(api: api, reset: true) + } + } + + /// 切换钱包明细 Tab 并刷新对应列表。 + func selectTab(_ tab: WalletLedgerTab, api: WalletServing) async { + guard selectedTab != tab else { return } + selectedTab = tab + await reloadList(api: api) + } + + /// 切换收益明细时间筛选并重置分页。 + func selectFilter(_ filter: WalletDateFilter, api: WalletServing) async { + guard selectedFilter != filter else { return } + selectedFilter = filter + if selectedTab == .earnings { + await reloadList(api: api) + } + } + + /// 加载下一页当前列表。 + func loadMore(api: WalletServing) async { + switch selectedTab { + case .earnings where canLoadMoreEarnings: + earningsPage += 1 + await loadEarnings(api: api, reset: false) + case .withdraws where canLoadMoreWithdraws: + withdrawPage += 1 + await loadWithdraws(api: api, reset: false) + default: + break + } + } + + /// 根据实名认证和银行卡状态决定提现下一步。 + func resolveWithdrawDecision(profileAPI: WalletRealNameServing, walletAPI: WalletServing) async -> WalletWithdrawDecision { + isCheckingWithdraw = true + defer { isCheckingWithdraw = false } + + do { + let realName = try await profileAPI.realNameInfo().realNameInfo + guard let realName else { + return .route(.realNameAuth) + } + switch realName.auditStatus { + case 2: + let bankCard = try await walletAPI.bankCardInfo().bankCard + guard let bankCard else { + return .route(.bankCardSettings) + } + if bankCard.auditStatus == 2 { + return .route(.withdrawApply) + } + if bankCard.auditStatus == 3 { + return .route(.bankCardSettings) + } + return .message("银行卡审核中,请审核通过后再试") + case 3: + return .route(.realNameAuth) + default: + return .message("实名认证审核中,请审核通过后再试") + } + } catch { + return .message(error.localizedDescription) + } + } + + /// 加载收益明细分页。 + private func loadEarnings(api: WalletServing, reset: Bool) async { + isLoadingList = true + errorMessage = nil + defer { isLoadingList = false } + + let range = selectedFilter.range() + do { + let response = try await api.walletEarningDetail( + startDate: range.start, + endDate: range.end, + page: earningsPage, + pageSize: pageSize + ) + earningsTotal = response.total + earningsGroups = reset ? response.list : earningsGroups + response.list + } catch { + if !reset { earningsPage = max(1, earningsPage - 1) } + if reset { earningsGroups = [] } + errorMessage = error.localizedDescription + } + } + + /// 加载提现记录分页。 + private func loadWithdraws(api: WalletServing, reset: Bool) async { + isLoadingList = true + errorMessage = nil + defer { isLoadingList = false } + + do { + let response = try await api.walletWithdrawList(page: withdrawPage, pageSize: pageSize) + withdrawTotal = response.total + withdrawRecords = reset ? response.item : withdrawRecords + response.item + } catch { + if !reset { withdrawPage = max(1, withdrawPage - 1) } + if reset { withdrawRecords = [] } + errorMessage = error.localizedDescription + } + } + + /// 规范金额展示,空值按 0.00 处理。 + static func moneyText(_ rawValue: String?) -> String { + let text = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard let decimal = Decimal(string: text), decimal > 0 else { + return "0.00" + } + return NSDecimalNumber(decimal: decimal).stringValue + } +} + +@MainActor +/// 提现申请 ViewModel,管理提现信息、金额校验、短信验证码和提交。 +final class WithdrawApplyViewModel { + var onChange: (() -> Void)? + var info: WithdrawInfoResponse? { didSet { onChange?() } } + var amountText = "" { didSet { onChange?() } } + var smsCode = "" { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isSubmitting = false { didSet { onChange?() } } + var smsCountdown = 0 { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 加载提现申请所需信息。 + func load(api: WalletServing) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + info = try await api.withdrawInfo() + } catch { + errorMessage = error.localizedDescription + } + } + + /// 发送提现短信验证码。 + func sendSms(api: WalletServing) async { + guard smsCountdown == 0 else { return } + do { + try await api.withdrawSendSms() + smsCountdown = 60 + } catch { + errorMessage = error.localizedDescription + } + } + + /// 提交提现申请,成功后清空表单。 + func submit(api: WalletServing) async -> Bool { + guard !isSubmitting else { return false } + guard let amount = PaymentCollectionViewModel.normalizedMoney(amountText) else { + errorMessage = "请输入有效提现金额" + return false + } + guard let info else { + errorMessage = "提现信息未加载" + return false + } + guard (Decimal(string: amount) ?? 0) <= (Decimal(string: info.amountWithdrawable) ?? 0) else { + errorMessage = "提现金额不能超过可提现金额" + return false + } + guard !smsCode.walletTrimmed.isEmpty else { + errorMessage = "请输入短信验证码" + return false + } + + isSubmitting = true + defer { isSubmitting = false } + do { + try await api.withdrawApply(amount: amount, smsCode: smsCode.walletTrimmed) + amountText = "" + smsCode = "" + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } +} + +@MainActor +/// 银行卡设置 ViewModel,管理银行卡表单、图片上传、短信验证码和提交。 +final class WithdrawalSettingsViewModel { + var onChange: (() -> Void)? + var bankCard: WalletBankCardInfo? { didSet { onChange?() } } + var banks: [String] = [] { didSet { onChange?() } } + var areas: [AreaNode] = [] { didSet { onChange?() } } + var realName = "" { didSet { onChange?() } } + var cardNumber = "" { didSet { onChange?() } } + var bankName = "" { didSet { onChange?() } } + var branchName = "" { didSet { onChange?() } } + var provinceCode = "" { didSet { onChange?() } } + var cityCode = "" { didSet { onChange?() } } + var smsCode = "" { didSet { onChange?() } } + var frontImageData: Data? { didSet { onChange?() } } + var backImageData: Data? { didSet { onChange?() } } + var frontImageURL = "" { didSet { onChange?() } } + var backImageURL = "" { didSet { onChange?() } } + var uploadProgress = 0 { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isSubmitting = false { didSet { onChange?() } } + var smsCountdown = 0 { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + /// 加载银行卡设置需要的银行、地区和已提交资料。 + func load(api: WalletServing) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + async let bankCardResult = api.bankCardInfo() + async let bankListResult = api.bankList() + async let areaResult = api.areas() + do { + let (cardResponse, bankResponse, areaResponse) = try await (bankCardResult, bankListResult, areaResult) + bankCard = cardResponse.bankCard + banks = bankResponse.banks + areas = areaResponse + apply(cardResponse.bankCard) + } catch { + errorMessage = error.localizedDescription + } + } + + /// 发送银行卡设置短信验证码。 + func sendSms(api: WalletServing) async { + guard smsCountdown == 0 else { return } + do { + try await api.bankCardVerifyCode() + smsCountdown = 60 + } catch { + errorMessage = error.localizedDescription + } + } + + /// 上传图片并提交银行卡资料。 + func submit(api: WalletServing, uploader: OSSUploadServing, scenicId: Int?) async -> Bool { + guard !isSubmitting else { return false } + guard let scenicId else { + errorMessage = "请先选择景区" + return false + } + guard validateForm() else { return false } + + isSubmitting = true + uploadProgress = 0 + defer { isSubmitting = false } + + do { + let frontURL = try await resolvedImageURL( + currentURL: frontImageURL, + data: frontImageData, + fileName: "bank_card_front.jpg", + uploader: uploader, + scenicId: scenicId + ) + let backURL = try await resolvedImageURL( + currentURL: backImageURL, + data: backImageData, + fileName: "bank_card_back.jpg", + uploader: uploader, + scenicId: scenicId + ) + let request = UpdateBankInfoRequest( + realName: realName.walletTrimmed, + cardNumber: cardNumber.walletTrimmed, + bankName: bankName.walletTrimmed, + branchName: branchName.walletTrimmed, + frontUrl: frontURL, + backUrl: backURL, + provinceCode: provinceCode.walletTrimmed, + cityCode: cityCode.walletTrimmed, + smsVerifyCode: smsCode.walletTrimmed + ) + try await api.updateBankInfo(request) + frontImageURL = frontURL + backImageURL = backURL + frontImageData = nil + backImageData = nil + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 将已有银行卡资料回填到表单。 + private func apply(_ info: WalletBankCardInfo?) { + guard let info else { return } + realName = info.realName + cardNumber = info.cardNumber + bankName = info.bankName + branchName = info.branchName + provinceCode = info.provinceCode ?? "" + cityCode = info.cityCode ?? "" + frontImageURL = info.frontUrl ?? "" + backImageURL = info.backUrl ?? "" + } + + /// 校验银行卡表单必填项。 + private func validateForm() -> Bool { + if realName.walletTrimmed.isEmpty { errorMessage = "请输入持卡人姓名"; return false } + if cardNumber.walletTrimmed.isEmpty { errorMessage = "请输入银行卡号"; return false } + if bankName.walletTrimmed.isEmpty { errorMessage = "请选择开户银行"; return false } + if branchName.walletTrimmed.isEmpty { errorMessage = "请输入开户支行"; return false } + if provinceCode.walletTrimmed.isEmpty || cityCode.walletTrimmed.isEmpty { errorMessage = "请选择开户地区"; return false } + if frontImageURL.walletTrimmed.isEmpty && frontImageData == nil { errorMessage = "请选择银行卡正面照片"; return false } + if backImageURL.walletTrimmed.isEmpty && backImageData == nil { errorMessage = "请选择银行卡反面照片"; return false } + if smsCode.walletTrimmed.isEmpty { errorMessage = "请输入短信验证码"; return false } + return true + } + + /// 返回最终图片 URL,本地选择的新图会先上传 OSS。 + private func resolvedImageURL( + currentURL: String, + data: Data?, + fileName: String, + uploader: OSSUploadServing, + scenicId: Int + ) async throws -> String { + guard let data else { return currentURL } + return try await uploader.uploadBankCardImage(data: data, fileName: fileName, scenicId: scenicId) { [weak self] progress in + self?.uploadProgress = progress + } + } +} + +@MainActor +/// 积分兑换 ViewModel,管理积分概览、兑换记录、兑换金额校验和提交。 +final class PointsRedemptionViewModel { + var onChange: (() -> Void)? + var overview = PointOverviewResponse() { didSet { onChange?() } } + var records: [PointWithdrawItem] = [] { didSet { onChange?() } } + var pointsText = "" { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isSubmitting = false { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 20 + private var page = 1 + private var total = 0 + + /// 是否还能继续加载兑换记录。 + var canLoadMore: Bool { + records.count < total + } + + /// 加载积分概览和兑换记录。 + func load(api: WalletServing, staffId: Int?) async { + guard let staffId else { + errorMessage = "缺少账号信息" + return + } + isLoading = true + errorMessage = nil + page = 1 + defer { isLoading = false } + do { + async let overviewResult = api.pointOverview(staffId: staffId) + async let listResult = api.pointWithdrawList(status: nil, page: page, pageSize: pageSize) + overview = try await overviewResult + let list = try await listResult + total = list.total + records = list.list + } catch { + errorMessage = error.localizedDescription + } + } + + /// 加载下一页积分兑换记录。 + func loadMore(api: WalletServing) async { + guard canLoadMore else { return } + page += 1 + do { + let response = try await api.pointWithdrawList(status: nil, page: page, pageSize: pageSize) + total = response.total + records += response.list + } catch { + page = max(1, page - 1) + errorMessage = error.localizedDescription + } + } + + /// 将可兑换积分全部填入表单。 + func fillAllPoints() { + pointsText = String(overview.withdrawnPoints) + } + + /// 提交积分兑换申请,成功后刷新概览和列表。 + func submit(api: WalletServing, staffId: Int?) async -> Bool { + guard !isSubmitting else { return false } + guard let points = Int(pointsText.walletTrimmed), points > 0 else { + errorMessage = "请输入有效兑换积分" + return false + } + guard points <= overview.withdrawnPoints else { + errorMessage = "兑换积分不能超过可兑换积分" + return false + } + guard let staffId else { + errorMessage = "缺少账号信息" + return false + } + + isSubmitting = true + defer { isSubmitting = false } + do { + try await api.pointWithdrawApply(points: points, remark: "积分提现申请") + pointsText = "" + await load(api: api, staffId: staffId) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } +} + +private extension String { + /// 钱包模块内部使用的去空白文本,避免依赖其他文件的 fileprivate 扩展。 + var walletTrimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/suixinkan_ios/Features/Wallet/Wallet.md b/suixinkan_ios/Features/Wallet/Wallet.md new file mode 100644 index 0000000..8c0aff5 --- /dev/null +++ b/suixinkan_ios/Features/Wallet/Wallet.md @@ -0,0 +1,31 @@ +# 钱包模块 + +## 模块职责 + +`Features/Wallet` 承接首页 `wallet` 权限入口,负责个人钱包首页、收益明细、提现记录、提现申请、银行卡设置和积分兑换。 + +## 代码结构 + +- `WalletAPI`:封装钱包、提现、银行卡、地区、积分相关接口。 +- `WalletViewModel`:管理钱包汇总、收益明细分页、提现记录分页和提现资格判断。 +- `WithdrawApplyViewModel`:管理提现信息、金额校验、短信验证码和提现提交。 +- `WithdrawalSettingsViewModel`:管理银行卡资料、图片上传和银行卡更新提交。 +- `PointsRedemptionViewModel`:管理积分概览、兑换记录和兑换申请。 +- `WalletView`:钱包首页和钱包内部二级路由入口。 + +## 提现资格流 + +点击提现后先查询实名认证: + +1. 未提交或驳回:进入已迁移的 `RealNameAuthView`。 +2. 审核中:提示等待实名认证审核通过。 +3. 实名认证通过:查询银行卡资料。 +4. 无银行卡或银行卡驳回:进入 `WithdrawalSettingsView`。 +5. 银行卡审核中:提示等待审核。 +6. 银行卡通过:进入 `WithdrawApplyView`。 + +## 上传与缓存边界 + +银行卡正反面照片使用 `PhotosPicker` 选择后,通过 `OSSUploadService.uploadBankCardImage` 上传到 `bank_card/yyyyMMdd/scenicId/...` 路径,再把最终 URL 提交给服务端。 + +钱包数据只保存在各 ViewModel 内存中。短信验证码、OSS STS、提现表单、银行卡图片 Data 和积分兑换表单均不落盘。 diff --git a/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift b/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift new file mode 100644 index 0000000..224b7ec --- /dev/null +++ b/suixinkan_ios/Features/WithdrawalAudit/ViewControllers/WithdrawalAuditViewController.swift @@ -0,0 +1,60 @@ +// +// WithdrawalAuditViewController.swift +// suixinkan +// +// Created by Codex on 2026/6/26. +// + +import UIKit + +/// 提现审核列表页。 +final class WithdrawalAuditViewController: ModuleTableViewController { + private let viewModel = WithdrawalAuditViewModel() + + override func viewDidLoad() { + title = "提现审核" + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "全部", + style: .plain, + target: self, + action: #selector(cycleFilter) + ) + super.viewDidLoad() + wireViewModel(viewModel) { [weak self] in self?.updateFilterTitle() } + } + + override func tableRowCount() -> Int { + viewModel.filteredRecords.count + } + + override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) { + let record = viewModel.filteredRecords[indexPath.row] + cell.configure(title: "¥\(record.amount)", subtitle: record.statusLabel, detail: record.createdAt) + } + + override func reloadContent() async { + await viewModel.reload(api: services.walletAPI) + updateFilterTitle() + } + + override func willDisplayTableRow(at indexPath: IndexPath) { + guard indexPath.row >= viewModel.filteredRecords.count - 2 else { return } + Task { await viewModel.loadMore(api: services.walletAPI) } + } + + @objc private func cycleFilter() { + let filters = WithdrawalAuditFilter.allCases + guard let index = filters.firstIndex(of: viewModel.selectedFilter) else { return } + let next = filters[(index + 1) % filters.count] + Task { + viewModel.selectFilter(next) + reloadTable() + } + } + + private func updateFilterTitle() { + navigationItem.rightBarButtonItem?.title = viewModel.selectedFilter.title + } +} + +extension WithdrawalAuditViewModel: ViewModelBindable {} diff --git a/suixinkan_ios/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift b/suixinkan_ios/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift new file mode 100644 index 0000000..5befab8 --- /dev/null +++ b/suixinkan_ios/Features/WithdrawalAudit/ViewModels/WithdrawalAuditViewModel.swift @@ -0,0 +1,138 @@ +// +// WithdrawalAuditViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/25. +// + +import Foundation + +/// 提现审核筛选项,按提现记录的状态文案聚合审核视角。 +enum WithdrawalAuditFilter: String, CaseIterable, Identifiable { + case all + case processing + case completed + + var id: String { rawValue } + + /// 筛选项展示标题。 + var title: String { + switch self { + case .all: "全部" + case .processing: "处理中" + case .completed: "已完成" + } + } +} + +@MainActor +/// 提现审核 ViewModel,负责提现记录分页、状态筛选和失败状态清理。 +final class WithdrawalAuditViewModel { + var onChange: (() -> Void)? + var records: [WalletWithdrawRecord] = [] { didSet { onChange?() } } + var selectedFilter: WithdrawalAuditFilter = .all { didSet { onChange?() } } + var isLoading = false { didSet { onChange?() } } + var isLoadingMore = false { didSet { onChange?() } } + var loadFailed = false { didSet { onChange?() } } + var loadFailureReason: String? { didSet { onChange?() } } + var errorMessage: String? { didSet { onChange?() } } + + private let pageSize = 10 + private var page = 1 + private var total = 0 + + /// 当前筛选下的提现记录。 + var filteredRecords: [WalletWithdrawRecord] { + filteredRecords(for: selectedFilter) + } + + /// 是否还有下一页。 + var hasMore: Bool { + records.count < total + } + + /// 记录总数,优先使用服务端 total。 + var totalCount: Int { + max(total, records.count) + } + + /// 处理中数量。 + var processingCount: Int { + filteredRecords(for: .processing).count + } + + /// 已完成数量。 + var completedCount: Int { + filteredRecords(for: .completed).count + } + + /// 按指定筛选返回提现记录。 + func filteredRecords(for filter: WithdrawalAuditFilter) -> [WalletWithdrawRecord] { + switch filter { + case .all: + return records + case .processing: + return records.filter { record in + let status = record.statusLabel + return status.contains("中") || status.contains("待") + } + case .completed: + return records.filter { record in + let status = record.statusLabel + return status.contains("完成") || status.contains("到账") || status.contains("通过") + } + } + } + + /// 切换筛选项,仅影响本地展示。 + func selectFilter(_ filter: WithdrawalAuditFilter) { + selectedFilter = filter + } + + /// 重新加载第一页提现审核记录。 + func reload(api: WalletServing) async { + isLoading = true + loadFailed = false + loadFailureReason = nil + errorMessage = nil + defer { isLoading = false } + + do { + let response = try await api.walletWithdrawList(page: 1, pageSize: pageSize) + records = response.item + total = response.total + page = 1 + } catch { + clearRecords() + loadFailed = true + loadFailureReason = error.localizedDescription + errorMessage = error.localizedDescription + } + } + + /// 加载下一页提现审核记录。 + func loadMore(api: WalletServing) async { + guard hasMore, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + errorMessage = nil + defer { isLoadingMore = false } + + let nextPage = page + 1 + do { + let response = try await api.walletWithdrawList(page: nextPage, pageSize: pageSize) + records += response.item + total = response.total + page = nextPage + } catch { + errorMessage = error.localizedDescription + } + } + + /// 清空旧提现审核数据,避免失败后残留上一账号或上一状态记录。 + private func clearRecords() { + records = [] + page = 1 + total = 0 + isLoadingMore = false + } +} diff --git a/suixinkan_ios/Features/WithdrawalAudit/WithdrawalAudit.md b/suixinkan_ios/Features/WithdrawalAudit/WithdrawalAudit.md new file mode 100644 index 0000000..6da5588 --- /dev/null +++ b/suixinkan_ios/Features/WithdrawalAudit/WithdrawalAudit.md @@ -0,0 +1,16 @@ +# 提现审核模块 + +## 模块职责 + +`Features/WithdrawalAudit` 承接首页 `withdrawal_audit` 权限入口,以审核视角展示钱包提现记录、状态筛选和单笔处理进度。 + +## 业务流程 + +- `WithdrawalAuditView` 进入后调用 `WalletAPI.walletWithdrawList` 加载第一页提现记录。 +- `WithdrawalAuditViewModel` 管理分页、加载更多、筛选状态和失败清理。 +- 筛选只在本地执行:处理中匹配状态文案中的“中/待”,已完成匹配“完成/到账/通过”。 +- 刷新失败会清空旧记录和分页状态,避免账号或权限切换后残留上一组财务数据。 + +## 边界 + +本模块不做管理员提现审批操作。旧 Android 工程未找到提现审批列表或操作接口,当前按旧 iOS 的提现记录审核视角迁移。 diff --git a/suixinkan_ios/Info.plist b/suixinkan_ios/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/suixinkan_ios/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/suixinkan_ios/Resources/loading.json b/suixinkan_ios/Resources/loading.json new file mode 100644 index 0000000..7bd0c82 --- /dev/null +++ b/suixinkan_ios/Resources/loading.json @@ -0,0 +1 @@ +{"v":"4.8.0","meta":{"g":"LottieFiles AE 1.0.0","a":"","k":"","d":"","tc":"#FFFFFF"},"fr":25,"ip":25,"op":55,"w":800,"h":800,"nm":"Loading #18","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Line","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[20]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[20]}],"ix":11},"r":{"a":0,"k":90,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[-175,46,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-2,0]],"o":[[0,0],[2,0]],"v":[[-268,46],[-82,46]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.2745,0.3608,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":39,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[45]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[0]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[45]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[0]},{"t":60,"s":[45]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":0,"s":[55]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":15,"s":[100]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":30,"s":[55]},{"i":{"x":[0.453],"y":[1]},"o":{"x":[0.538],"y":[0]},"t":45,"s":[100]},{"t":60,"s":[55]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":125,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Line 5","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[540,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":25,"op":80,"st":25,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"Line 4","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[470,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":22,"op":77,"st":22,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Line 3","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[400,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":19,"op":74,"st":19,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Line 2","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[330,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":16,"op":71,"st":16,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"Line 1","parent":3,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[260,400,0],"ix":2},"a":{"a":0,"k":[400,400,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":800,"h":800,"ip":13,"op":68,"st":13,"bm":0}],"markers":[]} diff --git a/suixinkan_ios/SceneDelegate.swift b/suixinkan_ios/SceneDelegate.swift new file mode 100644 index 0000000..df5c7bf --- /dev/null +++ b/suixinkan_ios/SceneDelegate.swift @@ -0,0 +1,43 @@ +// +// SceneDelegate.swift +// suixinkan_ios +// +// Created by hanqiu on 2026/6/26. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = scene as? UIWindowScene else { return } + + AppUITestLaunchState.resetIfNeeded() + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = RootViewController() + window.makeKeyAndVisible() + self.window = window + } + + func sceneDidDisconnect(_ scene: UIScene) { + } + + func sceneDidBecomeActive(_ scene: UIScene) { + } + + func sceneWillResignActive(_ scene: UIScene) { + } + + func sceneWillEnterForeground(_ scene: UIScene) { + } + + func sceneDidEnterBackground(_ scene: UIScene) { + } +} diff --git a/suixinkan_iosTests/APIErrorTests.swift b/suixinkan_iosTests/APIErrorTests.swift new file mode 100644 index 0000000..699ee1a --- /dev/null +++ b/suixinkan_iosTests/APIErrorTests.swift @@ -0,0 +1,29 @@ +// +// APIErrorTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/20. +// + +import XCTest +@testable import suixinkan_ios + +/// 网络错误测试,覆盖登录凭证失效判断。 +final class APIErrorTests: XCTestCase { + /// 测试 HTTP 401 和 403 会被识别为登录失效。 + func testAuthenticationExpiredForUnauthorizedHTTPStatus() { + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(401, "Unauthorized"))) + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(403, "Forbidden"))) + } + + /// 测试后端业务码和中文失效文案会被识别为登录失效。 + func testAuthenticationExpiredForServerTokenMessages() { + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.serverCode(200001, "token 已过期"))) + XCTAssertTrue(APIError.isAuthenticationExpired(APIError.serverCode(0, "登录失效,请重新登录"))) + } + + /// 测试普通网络错误不会被识别为登录失效。 + func testNetworkErrorIsNotAuthenticationExpired() { + XCTAssertFalse(APIError.isAuthenticationExpired(APIError.networkFailed("请求超时,请稍后重试"))) + } +} diff --git a/suixinkan_iosTests/AccountContextLoaderTests.swift b/suixinkan_iosTests/AccountContextLoaderTests.swift new file mode 100644 index 0000000..e6e0112 --- /dev/null +++ b/suixinkan_iosTests/AccountContextLoaderTests.swift @@ -0,0 +1,137 @@ +// +// AccountContextLoaderTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 账号上下文加载器测试,覆盖权限、景区兜底和门店容错。 +final class AccountContextLoaderTests: XCTestCase { + /// 测试权限、用户资料、景区和门店成功时会写入上下文。 + func testRefreshLoadsRolePermissionScenicAndStoreContext() async throws { + let accountContext = AccountContext() + let permissionContext = PermissionContext() + let api = MockAccountContextAPI() + api.roles = [ + RolePermissionResponse( + role: RoleInfo(id: 7, name: "运营", permission: [PermissionItem(id: 1, name: "首页", uri: "/home")]), + scenic: [ScenicInfo(id: 88, name: "东湖景区")] + ) + ] + api.scenicResponse = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 88, name: "东湖景区")]) + api.storeResponse = ListPayload(total: 1, list: [StoreItem(id: 66, scenicId: 88, name: "东湖门店")]) + + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: MockUserProfileAPI(), + accountContextAPI: api + ) + + XCTAssertEqual(accountContext.profile?.displayName, "测试用户") + XCTAssertEqual(permissionContext.currentRole?.id, 7) + XCTAssertTrue(permissionContext.canAccess("/home")) + XCTAssertEqual(accountContext.currentScenic?.id, 88) + XCTAssertEqual(accountContext.currentStore?.id, 66) + } + + /// 测试景区接口失败时会从角色权限景区兜底。 + func testRefreshFallsBackToRoleScenicsWhenScenicAPIThrows() async throws { + let accountContext = AccountContext() + let permissionContext = PermissionContext() + let api = MockAccountContextAPI() + api.roles = [ + RolePermissionResponse( + role: RoleInfo(id: 7, name: "运营"), + scenic: [ + ScenicInfo(id: 88, name: "东湖景区"), + ScenicInfo(id: 88, name: "东湖景区") + ] + ) + ] + api.scenicError = APIError.networkFailed("景区接口失败") + + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: MockUserProfileAPI(), + accountContextAPI: api + ) + + XCTAssertEqual(accountContext.scenicScopes.map(\.id), [88]) + } + + /// 测试门店接口失败时不阻断上下文加载。 + func testRefreshAllowsStoreFailure() async throws { + let accountContext = AccountContext() + let permissionContext = PermissionContext() + let api = MockAccountContextAPI() + api.roles = [ + RolePermissionResponse( + role: RoleInfo(id: 7, name: "运营"), + scenic: [ScenicInfo(id: 88, name: "东湖景区")] + ) + ] + api.storeError = APIError.networkFailed("门店接口失败") + + try await AccountContextLoader().refresh( + accountContext: accountContext, + permissionContext: permissionContext, + profileAPI: MockUserProfileAPI(), + accountContextAPI: api + ) + + XCTAssertEqual(accountContext.currentScenic?.id, 88) + XCTAssertTrue(accountContext.storeScopes.isEmpty) + XCTAssertNil(accountContext.currentStore) + } +} + +@MainActor +/// 用户资料服务测试替身,返回固定用户资料。 +private final class MockUserProfileAPI: UserProfileServing { + /// 获取固定用户资料。 + func userInfo() async throws -> UserInfoResponse { + UserInfoResponse(nickname: "测试用户", roleName: "运营") + } +} + +@MainActor +/// 账号上下文服务测试替身,可分别控制各接口成功或失败。 +private final class MockAccountContextAPI: AccountContextServing { + var roles: [RolePermissionResponse] = [] + var scenicResponse = ScenicListAllResponse(total: 0, list: []) + var storeResponse = ListPayload(total: 0, list: [StoreItem]()) + var scenicError: Error? + var storeError: Error? + + /// 获取测试角色权限。 + func rolePermissions() async throws -> [RolePermissionResponse] { + roles + } + + /// 获取测试景区列表,或按配置抛出错误。 + func scenicListAll() async throws -> ScenicListAllResponse { + if let scenicError { + throw scenicError + } + return scenicResponse + } + + /// 获取测试门店列表,或按配置抛出错误。 + func storeAll() async throws -> ListPayload { + if let storeError { + throw storeError + } + return storeResponse + } + + /// 获取测试景点列表。 + func scenicSpotListAll(scenicId: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } +} diff --git a/suixinkan_iosTests/AccountContextTests.swift b/suixinkan_iosTests/AccountContextTests.swift new file mode 100644 index 0000000..28127a6 --- /dev/null +++ b/suixinkan_iosTests/AccountContextTests.swift @@ -0,0 +1,133 @@ +// +// AccountContextTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/20. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 账号上下文测试,覆盖景区、门店和角色权限的选择恢复。 +final class AccountContextTests: XCTestCase { + /// 测试 replaceScopes 能按缓存 ID 恢复当前景区和门店。 + func testReplaceScopesRestoresCurrentSelectionsById() { + let context = AccountContext() + let scenicScopes = [ + BusinessScope(id: 1, name: "默认景区", kind: .scenic), + BusinessScope(id: 2, name: "缓存景区", kind: .scenic) + ] + let storeScopes = [ + BusinessScope(id: 10, name: "默认门店", kind: .store), + BusinessScope(id: 20, name: "缓存门店", kind: .store) + ] + + context.replaceScopes( + scenic: scenicScopes, + stores: storeScopes, + currentScenicId: 2, + currentStoreId: 20 + ) + + XCTAssertEqual(context.currentScenic?.id, 2) + XCTAssertEqual(context.currentStore?.id, 20) + } + + /// 测试缓存 ID 不存在时会回退到首个可用作用域。 + func testReplaceScopesFallsBackToFirstScopeWhenCachedIdIsMissing() { + let context = AccountContext() + + context.replaceScopes( + scenic: [BusinessScope(id: 1, name: "默认景区", kind: .scenic)], + stores: [BusinessScope(id: 10, name: "默认门店", kind: .store)], + currentScenicId: 999, + currentStoreId: 999 + ) + + XCTAssertEqual(context.currentScenic?.id, 1) + XCTAssertEqual(context.currentStore?.id, 10) + } + + /// 测试切换景区时会优先选择同景区门店。 + func testSelectScenicResolvesStoreWithinSelectedScenic() { + let context = AccountContext() + context.replaceScopes( + scenic: [ + BusinessScope(id: 1, name: "西湖景区", kind: .scenic), + BusinessScope(id: 2, name: "东湖景区", kind: .scenic) + ], + stores: [ + BusinessScope(id: 10, name: "西湖门店", kind: .store, parentScenicId: 1), + BusinessScope(id: 20, name: "东湖门店", kind: .store, parentScenicId: 2) + ], + currentScenicId: 1, + currentStoreId: 10 + ) + + context.selectScenic(id: 2) + + XCTAssertEqual(context.currentScenic?.id, 2) + XCTAssertEqual(context.currentStore?.id, 20) + } + + /// 测试切换角色时会同步角色景区并重新匹配门店。 + func testSelectRoleUpdatesScenicScopesAndStoreSelection() { + let accountContext = AccountContext() + let permissionContext = PermissionContext() + accountContext.replaceScopes( + scenic: [BusinessScope(id: 1, name: "旧景区", kind: .scenic)], + stores: [ + BusinessScope(id: 10, name: "旧门店", kind: .store, parentScenicId: 1), + BusinessScope(id: 20, name: "新门店", kind: .store, parentScenicId: 2) + ], + currentScenicId: 1, + currentStoreId: 10 + ) + permissionContext.replaceRolePermissions([ + RolePermissionResponse( + role: RoleInfo(id: 1, name: "旧角色"), + scenic: [ScenicInfo(id: 1, name: "旧景区")] + ), + RolePermissionResponse( + role: RoleInfo(id: 2, name: "新角色"), + scenic: [ScenicInfo(id: 2, name: "新景区")] + ) + ]) + + permissionContext.selectRole(id: 2, accountContext: accountContext) + + XCTAssertEqual(permissionContext.currentRole?.id, 2) + XCTAssertEqual(accountContext.scenicScopes.map(\.id), [2]) + XCTAssertEqual(accountContext.currentScenic?.id, 2) + XCTAssertEqual(accountContext.currentStore?.id, 20) + } + + /// 测试权限上下文会递归展开权限 URI。 + func testPermissionContextFlattensPermissionURIs() { + let permissionContext = PermissionContext() + permissionContext.replaceRolePermissions([ + RolePermissionResponse( + role: RoleInfo( + id: 1, + name: "运营", + permission: [ + PermissionItem( + id: 1, + name: "首页", + uri: "/home", + children: [ + PermissionItem(id: 2, name: "订单", uri: "/orders") + ] + ) + ] + ), + scenic: [] + ) + ]) + + XCTAssertTrue(permissionContext.canAccess("/home")) + XCTAssertTrue(permissionContext.canAccess("/orders")) + XCTAssertFalse(permissionContext.canAccess("/settings")) + } +} diff --git a/suixinkan_iosTests/Assets/AssetsAPITests.swift b/suixinkan_iosTests/Assets/AssetsAPITests.swift new file mode 100644 index 0000000..415bd21 --- /dev/null +++ b/suixinkan_iosTests/Assets/AssetsAPITests.swift @@ -0,0 +1,314 @@ +// +// AssetsAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 资产 API 测试,覆盖云盘和素材接口请求以及宽松模型解码。 +final class AssetsAPITests: XCTestCase { + /// 测试云盘列表接口使用正确 path 和 query。 + func testCloudFileListUsesExpectedPathAndQuery() async throws { + let session = AssetsRecordingURLSession(data: Self.cloudListResponse) + let api = AssetsAPI(client: APIClient(session: session)) + + let payload = try await api.cloudFileList(parentFolderId: 9, name: " 照片 ", type: 2, orderBy: 1, page: 0, pageSize: 0) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/cloud-driver/list") + let query = assetsQueryItems(from: request) + XCTAssertEqual(query["parent_folder_id"], "9") + XCTAssertEqual(query["name"], " 照片 ") + XCTAssertEqual(query["type"], "2") + XCTAssertEqual(query["order_by"], "1") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(payload.total, 2) + XCTAssertEqual(payload.list.dropFirst().first?.fileSize, 2048) + } + + /// 测试云盘写操作使用正确 path 和 body。 + func testCloudMutationRequestsUseExpectedBodies() async throws { + let session = AssetsRecordingURLSession(data: Self.emptyResponse) + let api = AssetsAPI(client: APIClient(session: session)) + + try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: 1, name: "新文件夹")) + try await api.cloudFileUpload(CloudFileUploadRequest(parentFolderId: 1, fileUrl: "https://cdn/a.jpg", fileName: "a.jpg")) + try await api.cloudFileDelete(CloudFileDeleteRequest(list: [CloudFileActionItem(id: 2, type: 99)])) + try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: 3, list: [CloudFileActionItem(id: 2, type: 2)])) + try await api.cloudFolderEdit(CloudFolderModifyRequest(id: 2, name: "改名")) + try await api.cloudFileEdit(CloudFileModifyRequest(id: 4, fileName: "b.jpg")) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/cloud-driver/folder-create", + "/api/yf-handset-app/photog/cloud-driver/file-upload", + "/api/yf-handset-app/photog/cloud-driver/delete", + "/api/yf-handset-app/photog/cloud-driver/move", + "/api/yf-handset-app/photog/cloud-driver/folder-edit", + "/api/yf-handset-app/photog/cloud-driver/file-edit" + ]) + let uploadBody = try bodyObject(from: session.requests[1]) + XCTAssertEqual(uploadBody["parent_folder_id"] as? Int, 1) + XCTAssertEqual(uploadBody["file_url"] as? String, "https://cdn/a.jpg") + let moveBody = try bodyObject(from: session.requests[3]) + XCTAssertEqual(moveBody["target_folder_id"] as? Int, 3) + } + + /// 测试云盘上传权限接口兼容字符串布尔值。 + func testCloudUploadPermissionDecodesLossyFields() async throws { + let session = AssetsRecordingURLSession(data: Self.permissionResponse) + let api = AssetsAPI(client: APIClient(session: session)) + + let permission = try await api.checkCloudUploadPermission() + + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/cloud-driver/check-upload-permission") + XCTAssertFalse(permission.canUpload) + XCTAssertEqual(permission.reason, "空间不足") + } + + /// 测试素材列表和详情接口使用正确 path 并宽松解码字段。 + func testMediaListAndDetailDecodeLossyFields() async throws { + let session = AssetsRecordingURLSession(responses: [Self.mediaListResponse, Self.mediaDetailResponse]) + let api = AssetsAPI(client: APIClient(session: session)) + + let list = try await api.mediaAlbumList(kind: .material, keyword: "航拍", status: 1, page: 0, pageSize: 0) + let detail = try await api.mediaAlbumDetail(id: 7, kind: .material) + + XCTAssertEqual(session.requests.first?.url?.path, "/api/app/media-album/list") + let query = assetsQueryItems(from: try XCTUnwrap(session.requests.first)) + XCTAssertEqual(query["type"], "1") + XCTAssertEqual(query["status"], "1") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(list.order?.totalNum, 12) + XCTAssertEqual(list.list.first?.likesCount, 3) + XCTAssertEqual(session.requests.last?.url?.path, "/api/app/media-album/detail") + XCTAssertEqual(assetsQueryItems(from: try XCTUnwrap(session.requests.last))["type"], "1") + XCTAssertEqual(detail.mediaList.first?.size, 4096) + XCTAssertTrue(detail.mediaList.first?.isVideo == true) + } + + /// 测试素材写操作 path 和请求体。 + func testMediaMutationRequestsUseExpectedBodies() async throws { + let session = AssetsRecordingURLSession(data: Self.emptyResponse) + let api = AssetsAPI(client: APIClient(session: session)) + let upload = MediaAlbumUploadRequest( + name: "素材", + type: 1, + mediaType: 2, + mediaList: [MediaAlbumUploadItem(originalName: "a.jpg", ossUrl: "https://cdn/a.jpg", size: 10, fileWidthSize: MediaAlbumFileSize(width: 100, height: 80))], + coverUrl: "https://cdn/c.jpg", + coverSize: MediaAlbumFileSize(width: 100, height: 80), + scenicSpotId: 6, + description: "描述", + materialTag: "航拍", + projectId: 0 + ) + + try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, type: 1, listingStatus: 1)) + try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1, type: 1)) + try await api.mediaAlbumAddTag(MediaAlbumAddTagRequest(name: "航拍")) + try await api.mediaAlbumUpload(upload) + try await api.mediaAlbumEdit(MediaAlbumEditRequest(id: 2, name: "素材", type: 1, mediaType: 2, mediaList: upload.mediaList, coverUrl: upload.coverUrl, coverSize: upload.coverSize, scenicSpotId: 6, description: "描述", materialTag: "航拍")) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/app/media-album/operation", + "/api/app/media-album/delete", + "/api/app/media-album/add-tag", + "/api/app/media-album/upload", + "/api/app/media-album/edit" + ]) + let body = try bodyObject(from: session.requests[3]) + XCTAssertEqual(body["scenic_spot_id"] as? Int, 6) + XCTAssertEqual((body["media_list"] as? [[String: Any]])?.first?["oss_url"] as? String, "https://cdn/a.jpg") + } + + /// 测试样片请求携带 type=2,项目列表接口和项目字段宽松解码正确。 + func testSampleRequestsAndProjectListUseExpectedTypeAndQuery() async throws { + let session = AssetsRecordingURLSession(responses: [ + Self.mediaListResponse, + Self.mediaDetailResponse, + Self.emptyResponse, + Self.emptyResponse, + Self.projectListResponse, + Self.emptyResponse + ]) + let api = AssetsAPI(client: APIClient(session: session)) + let upload = MediaAlbumUploadRequest( + name: "亲子样片", + type: 2, + mediaType: 2, + mediaList: [MediaAlbumUploadItem(originalName: "sample.jpg", ossUrl: "https://cdn/sample.jpg", size: 10, fileWidthSize: MediaAlbumFileSize(width: 100, height: 80))], + coverUrl: "https://cdn/cover.jpg", + coverSize: MediaAlbumFileSize(width: 100, height: 80), + scenicSpotId: 6, + description: "描述", + materialTag: "", + projectId: 88 + ) + + _ = try await api.mediaAlbumList(kind: .sample, keyword: "样片", status: nil, page: 1, pageSize: 10) + _ = try await api.mediaAlbumDetail(id: 7, kind: .sample) + try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 7, type: 2, listingStatus: 1)) + try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 7, type: 2)) + let projects = try await api.projectList(scenicId: 9, name: "亲子", page: 0, pageSize: 0) + try await api.mediaAlbumUpload(upload) + + XCTAssertEqual(assetsQueryItems(from: session.requests[0])["type"], "2") + XCTAssertEqual(assetsQueryItems(from: session.requests[1])["type"], "2") + XCTAssertEqual((try bodyObject(from: session.requests[2]))["type"] as? Int, 2) + XCTAssertEqual((try bodyObject(from: session.requests[3]))["type"] as? Int, 2) + XCTAssertEqual(session.requests[4].url?.path, "/api/yf-handset-app/photog/project/list") + let projectQuery = assetsQueryItems(from: session.requests[4]) + XCTAssertEqual(projectQuery["scenic_id"], "9") + XCTAssertEqual(projectQuery["name"], "亲子") + XCTAssertEqual(projectQuery["page"], "1") + XCTAssertEqual(projectQuery["page_size"], "1") + XCTAssertEqual(projects.list.first?.id, 88) + XCTAssertEqual(projects.list.first?.name, "亲子旅拍") + XCTAssertEqual((try bodyObject(from: session.requests[5]))["project_id"] as? Int, 88) + } + + /// 测试相册接口使用旧工程路径、query 和请求体。 + func testAlbumRequestsUseExpectedPathQueryAndBodies() async throws { + let session = AssetsRecordingURLSession(responses: [ + Self.albumFolderListResponse, + Self.albumFolderInfoResponse, + Self.albumFileListResponse, + Self.emptyResponse, + Self.emptyResponse, + Self.emptyResponse, + Self.emptyResponse + ]) + let api = AssetsAPI(client: APIClient(session: session)) + + let folders = try await api.albumFolderList( + scenicId: 9, + page: 0, + pageSize: 0, + name: "旅拍", + startTime: "2026-06-01", + endTime: "2026-06-24" + ) + let info = try await api.albumFolderInfo(id: 3) + let files = try await api.albumFileList(scenicId: 9, folderId: 3, fileType: 2, page: 0, pageSize: 0) + try await api.addAlbumFolder(scenicId: 9, name: "新相册", remark: "备注") + try await api.editAlbumFolder(folderId: 3, coverFileId: 11, name: "改名", remark: nil) + try await api.deleteAlbumFiles(folderId: 3, idList: [11, 12]) + try await api.albumFileUploadURL(scenicId: 9, fileURL: "https://cdn/a.jpg", folderId: 3) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/album/folder-list", + "/api/yf-handset-app/photog/album/folder-info", + "/api/yf-handset-app/photog/album/file-list", + "/api/yf-handset-app/photog/album/folder-add", + "/api/yf-handset-app/photog/album/folder-edit", + "/api/yf-handset-app/photog/album/file-delete", + "/api/yf-handset-app/photog/album/file-upload-url" + ]) + let folderQuery = assetsQueryItems(from: session.requests[0]) + XCTAssertEqual(folderQuery["scenic_id"], "9") + XCTAssertEqual(folderQuery["page"], "1") + XCTAssertEqual(folderQuery["page_size"], "1") + XCTAssertEqual(folderQuery["cloud_folder_type"], "1") + XCTAssertEqual(folderQuery["name"], "旅拍") + XCTAssertEqual(folderQuery["start_time"], "2026-06-01") + XCTAssertEqual(folderQuery["end_time"], "2026-06-24") + XCTAssertEqual(folders.list.first?.countImage, 4) + XCTAssertEqual(info.coverFile?.fileSize, 2048) + XCTAssertTrue(files.list.first?.isImage == true) + + let addBody = try bodyObject(from: session.requests[3]) + XCTAssertEqual(addBody["scenic_id"] as? String, "9") + XCTAssertEqual(addBody["cloud_folder_type"] as? Int, 1) + let editBody = try bodyObject(from: session.requests[4]) + XCTAssertEqual(editBody["cover_file_id"] as? Int, 11) + let deleteBody = try bodyObject(from: session.requests[5]) + XCTAssertEqual(deleteBody["folder_id"] as? Int, 3) + XCTAssertEqual(deleteBody["id_list"] as? [Int], [11, 12]) + let uploadQuery = assetsQueryItems(from: session.requests[6]) + XCTAssertEqual(uploadQuery["file_url"], "https://cdn/a.jpg") + } + + fileprivate static let emptyResponse = #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)! + fileprivate static let permissionResponse = #"{"code":100000,"msg":"success","data":{"can_upload":"0","reason":"空间不足"}}"#.data(using: .utf8)! + fileprivate static let cloudListResponse = """ + {"code":100000,"msg":"success","data":{"total":"2","list":[ + {"id":"1","parent_folder_id":0,"file_url":"","cover_url":"","updated_at":"2026","name":"文件夹","created_at":"2026","child_num":"3","type":"99","file_size":"0"}, + {"id":2,"parent_folder_id":"0","file_url":"https://cdn/a.jpg","cover_url":"","updated_at":"2026","name":"a.jpg","created_at":"2026","child_num":0,"type":2,"file_size":"2048"} + ]}} + """.data(using: .utf8)! + fileprivate static let mediaListResponse = """ + {"code":100000,"msg":"success","data":{"total":"1","order":{"total_num":"12","avg_order_amount":"8.5","refund_total":1,"avg_change":"0.2"},"list":[ + {"id":"7","name":"航拍","cover_url":"https://cdn/c.jpg","created_at":"2026","status":"1","download_count":"2","likes_count":"3","collect_count":"4","share_count":"5","scenic_spot_name":"东门","project_name":"项目","listing_status":"1"} + ]}} + """.data(using: .utf8)! + fileprivate static let mediaDetailResponse = """ + {"code":100000,"msg":"success","data":{"id":"7","name":"航拍","cover_url":"https://cdn/c.jpg","description":"描述","status":"1","listing_status":"1","likes_count":"3","download_count":"2","collect_count":"4","share_count":"5","uploader_name":"摄影师","created_at":"2026","scenic_name":"景区","project_name":"项目","media_list":[ + {"id":"9","original_name":"v.mp4","oss_url":"https://cdn/v.mp4","thumbnail_url":"","type":"1","size":"4096","show_url":"https://cdn/v.mp4"} + ]}} + """.data(using: .utf8)! + fileprivate static let projectListResponse = """ + {"code":100000,"msg":"success","data":{"total":"1","list":[ + {"id":"88","type":"19","type_name":"多点旅拍","status":"1","status_name":"上架","name":"亲子旅拍","cover_project":"https://cdn/p.jpg","cover_video":"","price":"299","ot_price":"399","price_deposit":"99"} + ]}} + """.data(using: .utf8)! + fileprivate static let albumFolderListResponse = """ + {"code":100000,"msg":"success","data":{"total":"1","list":[ + {"id":"3","name":"旅拍","cover_file_id":"11","count_video":"2","count_image":"4","cover":{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""},"created_at":"2026","remark":"备注"} + ]}} + """.data(using: .utf8)! + fileprivate static let albumFolderInfoResponse = """ + {"code":100000,"msg":"success","data":{"id":"3","name":"旅拍","cover_file_id":"11","count_video":"2","count_image":"4","cover":{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""},"created_at":"2026","remark":"备注"}} + """.data(using: .utf8)! + fileprivate static let albumFileListResponse = """ + {"code":100000,"msg":"success","data":{"total":"1","list":[ + {"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""} + ]}} + """.data(using: .utf8)! +} + +/// 资产 API 测试用 URLSession,记录请求并返回固定响应。 +private final class AssetsRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化单一响应。 + init(data: Data) { + responses = [data] + } + + /// 初始化顺序响应。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回下一份响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? AssetsAPITests.emptyResponse : responses.removeFirst() + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response) + } +} + +/// 从请求中提取 query 字典。 +private func assetsQueryItems(from request: URLRequest) -> [String: String] { + guard + let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) +} + +/// 将请求体解析成字典。 +private func bodyObject(from request: URLRequest) throws -> [String: Any] { + let data = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) +} diff --git a/suixinkan_iosTests/Assets/AssetsViewModelTests.swift b/suixinkan_iosTests/Assets/AssetsViewModelTests.swift new file mode 100644 index 0000000..8081e1f --- /dev/null +++ b/suixinkan_iosTests/Assets/AssetsViewModelTests.swift @@ -0,0 +1,675 @@ +// +// AssetsViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 资产 ViewModel 测试,覆盖云盘和素材管理核心业务逻辑。 +final class AssetsViewModelTests: XCTestCase { + /// 测试云盘无上传权限时不会继续上传 OSS 或写入云盘。 + func testCloudUploadStopsWhenPermissionDenied() async { + let api = MockAssetsService() + api.uploadPermission = false + api.uploadReason = "空间不足" + let uploader = MockAssetsUploader() + let viewModel = CloudStorageViewModel() + + let success = await viewModel.upload( + localFiles: [CloudLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)], + scenicId: 9, + api: api, + uploadService: uploader, + transferStore: CloudTransferStore() + ) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.errorMessage, "空间不足") + XCTAssertEqual(uploader.uploadedFiles.count, 0) + XCTAssertEqual(api.cloudUploadRequests.count, 0) + } + + /// 测试云盘上传会先上传 OSS,再登记云盘文件并刷新列表。 + func testCloudUploadUploadsOSSBeforeCloudRecord() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + let transferStore = CloudTransferStore() + let viewModel = CloudStorageViewModel() + + let success = await viewModel.upload( + localFiles: [CloudLocalUploadFile(data: Data([1, 2]), fileName: "a.jpg", fileType: 2)], + scenicId: 9, + api: api, + uploadService: uploader, + transferStore: transferStore + ) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"]) + XCTAssertEqual(api.cloudUploadRequests.first?.fileUrl, "https://cdn.example.com/a.jpg") + XCTAssertEqual(transferStore.records.first?.status, .success) + } + + /// 测试云盘分页、进入文件夹和返回文件夹。 + func testCloudPaginationAndFolderNavigation() async { + let folder = CloudDriveFile(id: 2, parentFolderId: 0, fileUrl: "", name: "子目录", type: 99) + let api = MockAssetsService() + api.cloudPages = [ + ListPayload(total: 2, list: [folder]), + ListPayload(total: 2, list: [CloudDriveFile(id: 3, parentFolderId: 0, fileUrl: "https://cdn/a.jpg", name: "a.jpg", type: 2)]) + ] + let viewModel = CloudStorageViewModel() + + await viewModel.reload(api: api) + await viewModel.loadMore(api: api) + await viewModel.enterFolder(folder, api: api) + await viewModel.popToFolder(at: 0, api: api) + + XCTAssertEqual(api.cloudListRequests.first?.parentFolderId, 0) + XCTAssertEqual(api.cloudListRequests[1].page, 2) + XCTAssertEqual(api.cloudListRequests[2].parentFolderId, 2) + XCTAssertEqual(viewModel.currentFolderId, 0) + } + + /// 测试素材列表筛选和分页请求参数。 + func testMediaListFilterAndPagination() async { + let api = MockAssetsService() + api.mediaListResponses = [ + MediaLibraryListResponse.fixture(total: 2, ids: [1]), + MediaLibraryListResponse.fixture(total: 2, ids: [2]) + ] + let viewModel = MediaLibraryViewModel(kind: .material) + viewModel.keyword = " 航拍 " + viewModel.selectedAuditFilter = .approved + + await viewModel.reload(api: api) + await viewModel.loadMore(api: api) + + XCTAssertEqual(api.mediaListRequests.first?.keyword, "航拍") + XCTAssertEqual(api.mediaListRequests.first?.status, 1) + XCTAssertEqual(api.mediaListRequests.last?.page, 2) + XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) + } + + /// 测试样片列表请求使用样片类型。 + func testSampleMediaListUsesSampleKind() async { + let api = MockAssetsService() + let viewModel = MediaLibraryViewModel(kind: .sample) + + await viewModel.reload(api: api) + + XCTAssertEqual(api.mediaListRequests.first?.kind, .sample) + } + + /// 测试审核未通过素材禁止上下架。 + func testRejectedMediaCannotToggleListing() async { + let api = MockAssetsService() + let viewModel = MediaLibraryViewModel() + let item = MediaLibraryItem.fixture(id: 1, status: 2, listingStatus: 0) + + let success = await viewModel.toggleListing(item, api: api) + + XCTAssertFalse(success) + XCTAssertEqual(api.mediaOperationRequests.count, 0) + XCTAssertEqual(viewModel.errorMessage, "审核通过后才能上下架") + } + + /// 测试样片上下架会携带样片类型。 + func testSampleToggleListingUsesSampleType() async { + let api = MockAssetsService() + let viewModel = MediaLibraryViewModel(kind: .sample) + let item = MediaLibraryItem.fixture(id: 1, status: 1, listingStatus: 0) + + let success = await viewModel.toggleListing(item, api: api) + + XCTAssertTrue(success) + XCTAssertEqual(api.mediaOperationRequests.first?.type, 2) + } + + /// 测试上传素材会先上传封面和媒体文件,再提交最终 OSS URL。 + func testMediaUploadUploadsFilesBeforeSubmit() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + let viewModel = MediaLibraryEditorViewModel() + viewModel.name = "航拍素材" + viewModel.selectedSpotId = 6 + viewModel.tagsText = "航拍,夜景" + viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) + viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) + + let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"]) + XCTAssertEqual(api.mediaUploadRequests.first?.coverUrl, "https://cdn.example.com/cover.jpg") + XCTAssertEqual(api.mediaUploadRequests.first?.mediaList.first?.ossUrl, "https://cdn.example.com/a.jpg") + XCTAssertEqual(api.mediaUploadRequests.first?.materialTag, "航拍,夜景") + } + + /// 测试素材上传失败时不会提交素材接口。 + func testMediaUploadFailureDoesNotSubmit() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + uploader.shouldFail = true + let viewModel = MediaLibraryEditorViewModel() + viewModel.name = "航拍素材" + viewModel.selectedSpotId = 6 + viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) + viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) + + let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertEqual(api.mediaUploadRequests.count, 0) + } + + /// 测试样片上传缺少关联项目时不提交。 + func testSampleUploadRequiresProject() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + let viewModel = MediaLibraryEditorViewModel() + viewModel.name = "亲子样片" + viewModel.selectedSpotId = 6 + viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) + viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) + + let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.errorMessage, "请选择关联项目") + XCTAssertEqual(api.mediaUploadRequests.count, 0) + } + + /// 测试样片上传会先上传文件,再提交样片类型和关联项目。 + func testSampleUploadUploadsFilesAndSubmitsProject() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + let viewModel = MediaLibraryEditorViewModel() + viewModel.name = "亲子样片" + viewModel.selectedSpotId = 6 + viewModel.selectedProjectId = 88 + viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) + viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) + + let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"]) + XCTAssertEqual(api.mediaUploadRequests.first?.type, 2) + XCTAssertEqual(api.mediaUploadRequests.first?.projectId, 88) + } + + /// 测试重复提交样片会被忽略。 + func testSampleDuplicateSubmitIsIgnored() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + let viewModel = MediaLibraryEditorViewModel() + viewModel.name = "亲子样片" + viewModel.selectedSpotId = 6 + viewModel.selectedProjectId = 88 + viewModel.isSubmitting = true + viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) + viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) + + let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertEqual(api.mediaUploadRequests.count, 0) + XCTAssertEqual(uploader.uploadedFiles.count, 0) + } + + /// 测试相册列表无景区时清空数据且不请求接口。 + func testAlbumListClearsWithoutScenic() async { + let api = MockAssetsService() + let viewModel = AlbumListViewModel() + + await viewModel.reload(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.folders.isEmpty) + XCTAssertEqual(api.albumFolderListRequests.count, 0) + XCTAssertEqual(viewModel.errorMessage, "缺少当前景区,无法加载相册") + } + + /// 测试相册列表搜索、日期筛选和分页参数。 + func testAlbumListFilterAndPagination() async { + let api = MockAssetsService() + api.albumFolderPages = [ + ListPayload(total: 2, list: [AlbumFolderItem.fixture(id: 1, name: "旅拍")]), + ListPayload(total: 2, list: [AlbumFolderItem.fixture(id: 2, name: "亲子")]) + ] + let viewModel = AlbumListViewModel() + viewModel.searchText = " 旅拍 " + viewModel.startTime = "2026-06-01" + viewModel.endTime = "2026-06-24" + + await viewModel.reload(api: api, scenicId: 9) + await viewModel.loadMore(api: api, scenicId: 9) + + XCTAssertEqual(api.albumFolderListRequests.first?.name, "旅拍") + XCTAssertEqual(api.albumFolderListRequests.first?.startTime, "2026-06-01") + XCTAssertEqual(api.albumFolderListRequests.last?.page, 2) + XCTAssertEqual(viewModel.folders.map(\.id), [1, 2]) + } + + /// 测试新建相册成功后刷新列表。 + func testAlbumCreateRefreshesList() async { + let api = MockAssetsService() + api.albumFolderPages = [ListPayload(total: 1, list: [AlbumFolderItem.fixture(id: 3, name: "新相册")])] + let viewModel = AlbumListViewModel() + + let success = await viewModel.createFolder(name: " 新相册 ", remark: " 备注 ", scenicId: 9, api: api) + + XCTAssertTrue(success) + XCTAssertEqual(api.albumAddRequests.first?.name, "新相册") + XCTAssertEqual(api.albumAddRequests.first?.remark, "备注") + XCTAssertEqual(viewModel.folders.first?.id, 3) + } + + /// 测试相册详情加载信息和文件列表,切换 Tab 会重置分页。 + func testAlbumDetailLoadsInfoAndSwitchesTab() async { + let api = MockAssetsService() + api.albumFilePages = [ + ListPayload(total: 1, list: [AlbumFileItem.fixture(id: 11, fileType: 2)]), + ListPayload(total: 1, list: [AlbumFileItem.fixture(id: 12, fileType: 1)]) + ] + let viewModel = AlbumDetailViewModel(folderId: 3) + + await viewModel.reload(api: api, scenicId: 9) + await viewModel.selectTab(.video, api: api, scenicId: 9) + + XCTAssertEqual(api.albumInfoRequests, [3, 3]) + XCTAssertEqual(api.albumFileListRequests.map { $0.fileType }, [2, 1]) + XCTAssertEqual(viewModel.files.first?.id, 12) + XCTAssertEqual(viewModel.page, 1) + } + + /// 测试相册详情删除、设置封面和编辑信息会调用正确接口。 + func testAlbumDetailMutationsUseExpectedRequests() async { + let api = MockAssetsService() + let viewModel = AlbumDetailViewModel(folderId: 3) + let file = AlbumFileItem.fixture(id: 11, fileType: 2) + + _ = await viewModel.delete(file: file, scenicId: 9, api: api) + _ = await viewModel.setCover(file: file, scenicId: 9, api: api) + _ = await viewModel.rename(name: " 改名 ", scenicId: 9, api: api) + _ = await viewModel.updateRemark(" 新备注 ", scenicId: 9, api: api) + + XCTAssertEqual(api.albumDeleteRequests.first?.idList, [11]) + XCTAssertEqual(api.albumEditRequests[0].coverFileId, 11) + XCTAssertEqual(api.albumEditRequests[1].name, "改名") + XCTAssertEqual(api.albumEditRequests[2].remark, "新备注") + } + + /// 测试相册预览上传会先上传 OSS,再登记相册文件。 + func testAlbumTrailerUploadUploadsOSSBeforeRegisteringFile() async { + let api = MockAssetsService() + api.albumFolderPages = [ListPayload(total: 1, list: [AlbumFolderItem.fixture(id: 3, name: "旅拍")])] + let uploader = MockAssetsUploader() + let viewModel = AlbumTrailerViewModel() + viewModel.addLocalFiles([AlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)]) + await viewModel.loadFolders(api: api, scenicId: 9) + + let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"]) + XCTAssertEqual(api.albumUploadURLRequests.first?.fileURL, "https://cdn.example.com/a.jpg") + XCTAssertTrue(viewModel.localFiles.isEmpty) + XCTAssertEqual(viewModel.uploadProgress, 100) + } + + /// 测试相册上传失败时不会登记文件入库。 + func testAlbumTrailerUploadFailureDoesNotRegisterFile() async { + let api = MockAssetsService() + let uploader = MockAssetsUploader() + uploader.shouldFail = true + let viewModel = AlbumTrailerViewModel() + viewModel.selectedFolderId = 3 + viewModel.addLocalFiles([AlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)]) + + let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertEqual(api.albumUploadURLRequests.count, 0) + } +} + +/// 资产服务测试替身,记录请求并返回预设数据。 +@MainActor +private final class MockAssetsService: AssetsServing { + struct CloudListRequest: Equatable { + let parentFolderId: Int + let name: String + let type: Int + let orderBy: Int + let page: Int + let pageSize: Int + } + + struct MediaListRequest: Equatable { + let kind: MediaLibraryKind + let keyword: String + let status: Int? + let page: Int + } + + struct ProjectListRequest: Equatable { + let scenicId: Int + let name: String? + let page: Int + let pageSize: Int + } + + struct AlbumFolderListRequest: Equatable { + let scenicId: Int + let page: Int + let pageSize: Int + let name: String + let startTime: String? + let endTime: String? + } + + struct AlbumUploadURLRequest: Equatable { + let scenicId: Int + let fileURL: String + let folderId: Int + } + + var cloudPages: [ListPayload] = [ListPayload(total: 0, list: [])] + var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])] + var projectPages: [ListPayload] = [ListPayload(total: 0, list: [])] + var albumFolderPages: [ListPayload] = [ListPayload(total: 0, list: [])] + var albumFilePages: [ListPayload] = [ListPayload(total: 0, list: [])] + var uploadPermission = true + var uploadReason = "" + var cloudListRequests: [CloudListRequest] = [] + var cloudUploadRequests: [CloudFileUploadRequest] = [] + var mediaListRequests: [MediaListRequest] = [] + var mediaOperationRequests: [MediaAlbumOperationRequest] = [] + var mediaUploadRequests: [MediaAlbumUploadRequest] = [] + var mediaEditRequests: [MediaAlbumEditRequest] = [] + var projectListRequests: [ProjectListRequest] = [] + var albumFolderListRequests: [AlbumFolderListRequest] = [] + var albumInfoRequests: [Int] = [] + var albumFileListRequests: [(scenicId: Int, folderId: Int, fileType: Int, page: Int)] = [] + var albumAddRequests: [(scenicId: Int, name: String, remark: String)] = [] + var albumEditRequests: [AlbumFolderEditRequest] = [] + var albumDeleteRequests: [AlbumFileDeleteRequest] = [] + var albumUploadURLRequests: [AlbumUploadURLRequest] = [] + + /// 返回云盘文件列表。 + func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload { + cloudListRequests.append(CloudListRequest(parentFolderId: parentFolderId, name: name, type: type, orderBy: orderBy, page: page, pageSize: pageSize)) + return cloudPages.isEmpty ? ListPayload(total: 0, list: []) : cloudPages.removeFirst() + } + + /// 创建文件夹。 + func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {} + + /// 登记云盘文件。 + func cloudFileUpload(_ request: CloudFileUploadRequest) async throws { + cloudUploadRequests.append(request) + } + + /// 删除云盘文件。 + func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {} + + /// 移动云盘文件。 + func cloudFileMove(_ request: CloudFileMoveRequest) async throws {} + + /// 重命名文件夹。 + func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {} + + /// 重命名文件。 + func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {} + + /// 检查上传权限。 + func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse { + try JSONDecoder().decode(CheckCloudUploadResponse.self, from: #"{"can_upload":\#(uploadPermission ? 1 : 0),"reason":"\#(uploadReason)"}"#.data(using: .utf8)!) + } + + /// 返回素材列表。 + func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse { + mediaListRequests.append(MediaListRequest(kind: kind, keyword: keyword, status: status, page: page)) + return mediaListResponses.isEmpty ? MediaLibraryListResponse.fixture(total: 0, ids: []) : mediaListResponses.removeFirst() + } + + /// 返回素材详情。 + func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail { + MediaLibraryDetail.fixture(id: id) + } + + /// 更新上下架。 + func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws { + mediaOperationRequests.append(request) + } + + /// 删除素材。 + func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {} + + /// 新增标签。 + func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws {} + + /// 上传素材。 + func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws { + mediaUploadRequests.append(request) + } + + /// 编辑素材。 + func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws { + mediaEditRequests.append(request) + } + + /// 返回样片可关联项目列表。 + func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload { + projectListRequests.append(ProjectListRequest(scenicId: scenicId, name: name, page: page, pageSize: pageSize)) + return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst() + } + + /// 返回相册文件夹列表。 + func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload { + albumFolderListRequests.append(AlbumFolderListRequest(scenicId: scenicId, page: page, pageSize: pageSize, name: name, startTime: startTime, endTime: endTime)) + return albumFolderPages.isEmpty ? ListPayload(total: 0, list: []) : albumFolderPages.removeFirst() + } + + /// 返回相册文件夹详情。 + func albumFolderInfo(id: Int) async throws -> AlbumFolderItem { + albumInfoRequests.append(id) + return AlbumFolderItem.fixture(id: id, name: "相册\(id)") + } + + /// 返回相册文件列表。 + func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload { + albumFileListRequests.append((scenicId: scenicId, folderId: folderId, fileType: fileType, page: page)) + return albumFilePages.isEmpty ? ListPayload(total: 0, list: []) : albumFilePages.removeFirst() + } + + /// 新建相册。 + func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws { + albumAddRequests.append((scenicId: scenicId, name: name, remark: remark)) + } + + /// 编辑相册。 + func editAlbumFolder(folderId: Int, coverFileId: Int?, name: String?, remark: String?) async throws { + albumEditRequests.append(AlbumFolderEditRequest(folderId: folderId, coverFileId: coverFileId, name: name, remark: remark)) + } + + /// 删除相册文件。 + func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws { + albumDeleteRequests.append(AlbumFileDeleteRequest(idList: idList, folderId: folderId)) + } + + /// 登记相册上传文件。 + func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws { + albumUploadURLRequests.append(AlbumUploadURLRequest(scenicId: scenicId, fileURL: fileURL, folderId: folderId)) + } +} + +/// OSS 上传测试替身,记录上传文件并返回固定 CDN URL。 +@MainActor +private final class MockAssetsUploader: OSSUploadServing { + struct UploadedFile: Equatable { + let fileName: String + let fileType: Int + let scenicId: Int + } + + var uploadedFiles: [UploadedFile] = [] + var shouldFail = false + + /// 上传用户头像。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传实名认证图片。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传云盘文件。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传相册文件。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传任务附件。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传项目图片。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传打卡点图片。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传景区申请图片。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传银行卡图片。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) + } + + /// 通用测试上传流程。 + private func upload(fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if shouldFail { + throw APIError.networkFailed("上传失败") + } + uploadedFiles.append(UploadedFile(fileName: fileName, fileType: fileType, scenicId: scenicId)) + onProgress(100) + return "https://cdn.example.com/\(fileName)" + } +} + +private extension MediaLibraryListResponse { + /// 创建素材列表测试数据。 + static func fixture(total: Int, ids: [Int]) -> MediaLibraryListResponse { + let list = ids.map { MediaLibraryItem.fixture(id: $0, status: 1, listingStatus: 0) } + let payload = [ + "total": total, + "list": list.map { ["id": $0.id, "name": $0.name, "status": $0.status, "listing_status": $0.listingStatus] }, + "order": ["total_num": 0, "avg_order_amount": 0, "refund_total": 0, "avg_change": 0] + ] as [String: Any] + let data = try! JSONSerialization.data(withJSONObject: payload) + return try! JSONDecoder().decode(MediaLibraryListResponse.self, from: data) + } +} + +private extension MediaLibraryItem { + /// 创建素材列表项测试数据。 + static func fixture(id: Int, status: Int, listingStatus: Int) -> MediaLibraryItem { + let payload: [String: Any] = [ + "id": id, + "name": "素材\(id)", + "cover_url": "", + "created_at": "", + "status": status, + "download_count": 0, + "likes_count": 0, + "collect_count": 0, + "share_count": 0, + "scenic_spot_name": "", + "project_name": "", + "listing_status": listingStatus + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + return try! JSONDecoder().decode(MediaLibraryItem.self, from: data) + } +} + +private extension MediaLibraryDetail { + /// 创建素材详情测试数据。 + static func fixture(id: Int) -> MediaLibraryDetail { + let payload: [String: Any] = [ + "id": id, + "name": "素材\(id)", + "cover_url": "", + "description": "", + "status": 1, + "listing_status": 0, + "likes_count": 0, + "download_count": 0, + "collect_count": 0, + "share_count": 0, + "uploader_name": "", + "created_at": "", + "media_list": [], + "scenic_name": "", + "project_name": "" + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + return try! JSONDecoder().decode(MediaLibraryDetail.self, from: data) + } +} + +private extension AlbumFolderItem { + /// 创建相册文件夹测试数据。 + static func fixture(id: Int, name: String) -> AlbumFolderItem { + let payload: [String: Any] = [ + "id": id, + "name": name, + "cover_file_id": 0, + "count_video": 1, + "count_image": 2, + "created_at": "2026", + "remark": "备注" + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + return try! JSONDecoder().decode(AlbumFolderItem.self, from: data) + } +} + +private extension AlbumFileItem { + /// 创建相册文件测试数据。 + static func fixture(id: Int, fileType: Int) -> AlbumFileItem { + let fileName = fileType == 1 ? "v.mp4" : "a.jpg" + let payload: [String: Any] = [ + "id": id, + "file_name": fileName, + "file_type": fileType, + "file_url": "https://cdn.example.com/\(fileName)", + "cover_url": "", + "file_size": "2048", + "file_size_human": "2KB", + "remark": "" + ] + let data = try! JSONSerialization.data(withJSONObject: payload) + return try! JSONDecoder().decode(AlbumFileItem.self, from: data) + } +} diff --git a/suixinkan_iosTests/AuthModelsTests.swift b/suixinkan_iosTests/AuthModelsTests.swift new file mode 100644 index 0000000..ff72603 --- /dev/null +++ b/suixinkan_iosTests/AuthModelsTests.swift @@ -0,0 +1,95 @@ +// +// AuthModelsTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/26. +// + +import XCTest +@testable import suixinkan_ios + +/// Auth 模块模型测试,覆盖 v9 登录响应解码和账号转换规则。 +final class AuthModelsTests: XCTestCase { + /// 测试多账号登录 fixture 能正确解码并合并景区、门店账号。 + func testV9AuthResponseDecodesMultiAccountFixture() throws { + let response = try TestFixture.payload(V9AuthResponse.self, named: "v9_login_multi_success") + + XCTAssertEqual(response.token, "person-temp-token") + XCTAssertEqual(response.scenicUsers.count, 1) + XCTAssertEqual(response.storeUsers.count, 1) + XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201]) + } + + /// 测试景区账号会优先使用 ss_user_id 作为业务账号 ID。 + func testV9ScenicUserBusinessUserIdPrefersSsUserId() throws { + let json = """ + { + "account_type": "scenic_user", + "id": 1, + "user_id": 2, + "scenic_user_id": 3, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "is_current": false + } + """.data(using: .utf8)! + let user = try JSONDecoder().decode(V9ScenicUser.self, from: json) + + XCTAssertEqual(user.businessUserId, 101) + XCTAssertEqual(user.toAccountSwitchAccount().accountType, V9ScenicUser.accountTypeValue) + XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(ssUserId: 101)) + } + + /// 测试门店账号会优先使用 store_user_id 作为业务账号 ID。 + func testV9StoreUserBusinessUserIdPrefersStoreUserId() throws { + let json = """ + { + "account_type": "store_user", + "id": 1, + "user_id": 2, + "store_user_id": 201, + "username": "store_admin", + "user_name": "store_admin", + "real_name": "张三", + "phone": "13800138000", + "avatar": "", + "scenic_id": 10, + "scenic_name": "示例景区", + "store_id": 20, + "store_name": "示例门店", + "is_current": false + } + """.data(using: .utf8)! + let user = try JSONDecoder().decode(V9StoreUser.self, from: json) + + XCTAssertEqual(user.businessUserId, 201) + XCTAssertEqual(user.toAccountSwitchAccount().accountType, V9StoreUser.accountTypeValue) + XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201)) + } + + /// 测试登录响应会派生景区和门店作用域,并去重。 + func testV9AuthResponseDerivesScopesFromAccounts() throws { + let response = try TestFixture.payload(V9AuthResponse.self, named: "v9_login_multi_success") + + XCTAssertEqual(response.scenicScopes.map(\.id), [10]) + XCTAssertEqual(response.storeScopes.map(\.id), [20]) + XCTAssertEqual(response.primaryProfile?.displayName, "示例门店") + } + + /// 测试 LoginRequest 会填充后端要求的默认字段。 + func testLoginRequestEncodesExpectedDefaults() throws { + let request = LoginRequest(username: "18651857230", password: "secret123") + let json = try JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any] + + XCTAssertEqual(json?["username"] as? String, "18651857230") + XCTAssertEqual(json?["password"] as? String, "secret123") + XCTAssertEqual(json?["type"] as? Int, 1) + XCTAssertEqual(json?["mobile"] as? String, "") + XCTAssertEqual(json?["code"] as? String, "") + } +} diff --git a/suixinkan_iosTests/Fixtures/alive_album_folder_detail_success.json b/suixinkan_iosTests/Fixtures/alive_album_folder_detail_success.json new file mode 100644 index 0000000..dfa108d --- /dev/null +++ b/suixinkan_iosTests/Fixtures/alive_album_folder_detail_success.json @@ -0,0 +1,26 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "id": "6101", + "album_id": "5101", + "name": "五一直播相册", + "creator": "摄影师小苏", + "items": [ + { + "id": "7101", + "url": "https://cdn.example.com/alive/photo.jpg", + "type": "1", + "size": "204800", + "cover_img": "" + }, + { + "id": 7102, + "url": "https://cdn.example.com/alive/video.mp4", + "type": 2, + "size": 10485760, + "cover_img": "https://cdn.example.com/alive/video-cover.jpg" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/alive_album_list_success.json b/suixinkan_iosTests/Fixtures/alive_album_list_success.json new file mode 100644 index 0000000..afa42d9 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/alive_album_list_success.json @@ -0,0 +1,34 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "items": [ + { + "id": "6101", + "album_id": "5101", + "name": "五一直播相册", + "creator": "摄影师小苏", + "items": [ + { + "id": "7101", + "url": "https://cdn.example.com/alive/photo.jpg", + "type": "1", + "size": "204800", + "cover_img": "" + }, + { + "id": 7102, + "url": "https://cdn.example.com/alive/video.mp4", + "type": 2, + "size": 10485760, + "cover_img": "https://cdn.example.com/alive/video-cover.jpg" + } + ] + } + ], + "page": "1", + "page_size": "1", + "total": "2", + "total_pages": "2" + } +} diff --git a/suixinkan_iosTests/Fixtures/alive_detail_success.json b/suixinkan_iosTests/Fixtures/alive_detail_success.json new file mode 100644 index 0000000..3dcddc0 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/alive_detail_success.json @@ -0,0 +1,18 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "id": "8101", + "title": "景区晨间直播", + "cover_img": "https://cdn.example.com/alive/cover.jpg", + "push_url": "rtmp://push.example.com/live/8101", + "start_time": "1779505200", + "end_time": "1779508800", + "duration": "3600", + "status": "2", + "status_label": "直播中", + "manaul_push_mode": "2", + "manaul_push_state": "1", + "views_count": "1234" + } +} diff --git a/suixinkan_iosTests/Fixtures/alive_list_success.json b/suixinkan_iosTests/Fixtures/alive_list_success.json new file mode 100644 index 0000000..634427c --- /dev/null +++ b/suixinkan_iosTests/Fixtures/alive_list_success.json @@ -0,0 +1,25 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "items": [ + { + "id": "8101", + "title": "景区晨间直播", + "cover_img": "https://cdn.example.com/alive/cover.jpg", + "push_url": "rtmp://push.example.com/live/8101", + "start_time": "1779505200", + "end_time": "1779508800", + "duration": "3600", + "status": "2", + "status_label": "直播中", + "manaul_push_mode": "2", + "manaul_push_state": "1", + "views_count": "1234" + } + ], + "total": "2", + "page": "1", + "page_size": "1" + } +} diff --git a/suixinkan_iosTests/Fixtures/available_order_success.json b/suixinkan_iosTests/Fixtures/available_order_success.json new file mode 100644 index 0000000..6216940 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/available_order_success.json @@ -0,0 +1,14 @@ +{ + "code": 100000, + "msg": "success", + "data": [ + { + "project_name": "亲子跟拍", + "order_number": "ORD-TASK-001", + "order_status": "20", + "order_status_label": "已支付", + "pay_time": "2026-06-21 09:55:00", + "user_phone": "13800000000" + } + ] +} diff --git a/suixinkan_iosTests/Fixtures/cloud_file_list_success.json b/suixinkan_iosTests/Fixtures/cloud_file_list_success.json new file mode 100644 index 0000000..d70add7 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/cloud_file_list_success.json @@ -0,0 +1,33 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "2", + "list": [ + { + "id": "501", + "parent_folder_id": "0", + "file_url": "", + "cover_url": "", + "updated_at": "2026-06-20 10:00:00", + "name": "任务素材", + "created_at": "2026-06-20 09:00:00", + "child_num": "3", + "type": "99", + "file_size": "0" + }, + { + "id": 502, + "parent_folder_id": 0, + "file_url": "https://cdn.example.com/cloud/photo.jpg", + "cover_url": "", + "updated_at": "2026-06-20 10:00:00", + "name": "photo.jpg", + "created_at": "2026-06-20 09:00:00", + "child_num": 0, + "type": 2, + "file_size": 2048 + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/deposit_order_list_success.json b/suixinkan_iosTests/Fixtures/deposit_order_list_success.json new file mode 100644 index 0000000..87c9f12 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/deposit_order_list_success.json @@ -0,0 +1,27 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "2", + "list": [ + { + "id": "4101", + "order_number": "DEP001", + "user_phone": "13800000000", + "amount": 99.5, + "status": "20", + "status_name": "待核销", + "created_at": "2026-05-23 09:00:00" + }, + { + "id": 4102, + "order_number": "DEP002", + "user_phone": 13900000000, + "amount": "199.00", + "status": 50, + "status_name": "已退款", + "created_at": "2026-05-22 18:30:00" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/empty_success.json b/suixinkan_iosTests/Fixtures/empty_success.json new file mode 100644 index 0000000..90b1942 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/empty_success.json @@ -0,0 +1,4 @@ +{ + "code": 100000, + "msg": "success" +} diff --git a/suixinkan_iosTests/Fixtures/flyer_detail_success.json b/suixinkan_iosTests/Fixtures/flyer_detail_success.json new file mode 100644 index 0000000..c293a02 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/flyer_detail_success.json @@ -0,0 +1,38 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "id": "801", + "flyer_nickname": "航拍摄影师", + "account_id": "42", + "realname_status": "2", + "status": "1", + "status_text": "审核中", + "created_at": "2026-05-23 04:11:00", + "updated_at": "2026-05-23 04:12:00", + "reviewer": "审核员", + "review_time": "2026-05-23 04:15:00", + "reject_reason": "", + "certificate_type": "2", + "certificate_no": "UAS-2026-001", + "certificate_start_date": "2026-01-01", + "certificate_end_date": "2028-01-01", + "certificate_image": "https://cdn.example.com/flyer/cert.jpg", + "drone_model": "DJI Mini", + "drone_sn": "SN20260523001", + "contact_phone": "13800000000", + "realname_status_text": "已实名", + "flyers_certification_logs": [ + { + "id": "9001", + "flyer_id": "801", + "operator": "审核员", + "action": "2", + "action_text": "通过", + "reject_reason": "", + "remark": "资料齐全", + "created_at": "2026-05-23 04:15:00" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/message_list_page2_success.json b/suixinkan_iosTests/Fixtures/message_list_page2_success.json new file mode 100644 index 0000000..d50d57b --- /dev/null +++ b/suixinkan_iosTests/Fixtures/message_list_page2_success.json @@ -0,0 +1,30 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "has_more": 0, + "last_id": 99, + "items": [ + { + "id": 100, + "type": 2, + "type_name": "核销", + "title": "", + "content": "订单已核销", + "push_at": "2026-05-23 02:50", + "created_at": "2026-05-23 02:50", + "is_read": "1" + }, + { + "id": 99, + "type": 0, + "type_name": "", + "title": "", + "content": "系统维护通知", + "push_at": "2026-05-23 02:40", + "created_at": "2026-05-23 02:40", + "is_read": false + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/message_list_success.json b/suixinkan_iosTests/Fixtures/message_list_success.json new file mode 100644 index 0000000..6802677 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/message_list_success.json @@ -0,0 +1,30 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "has_more": 1, + "last_id": "101", + "items": [ + { + "id": "101", + "type": "1", + "type_name": "订单", + "title": "新订单提醒", + "content": "你有新的拍摄订单", + "push_at": "2026-05-23 02:55", + "created_at": "2026-05-23 02:55", + "is_read": 0 + }, + { + "id": 100, + "type": 2, + "type_name": "核销", + "title": "", + "content": "订单已核销", + "push_at": "2026-05-23 02:50", + "created_at": "2026-05-23 02:50", + "is_read": "1" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/multi_travel_shoot_history_success.json b/suixinkan_iosTests/Fixtures/multi_travel_shoot_history_success.json new file mode 100644 index 0000000..8a91a47 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/multi_travel_shoot_history_success.json @@ -0,0 +1,36 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "project_name": "多点旅拍", + "project_type": "19", + "project_type_name": "多点旅拍", + "photog_spot_list": [ + { + "scenic_spot_id": "901", + "photog_uid": "42", + "scenic_spot_name": "湖心亭", + "photog_nickname": "小景", + "photog_name": "测试摄影师", + "files": [ + { + "file_name": "spot-901.jpg", + "file_url": "https://cdn.example.com/multi/spot-901.jpg", + "file_type": "1", + "file_size": "204800", + "cover_url": "", + "upload_time": "2026-05-23 04:22:00" + }, + { + "file_name": "spot-901.mp4", + "file_url": "https://cdn.example.com/multi/spot-901.mp4", + "file_type": 2, + "file_size": 1048576, + "cover_url": "https://cdn.example.com/multi/spot-901-cover.jpg", + "upload_time": "2026-05-23 04:23:00" + } + ] + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/multi_travel_verified_spots_success.json b/suixinkan_iosTests/Fixtures/multi_travel_verified_spots_success.json new file mode 100644 index 0000000..8e488f6 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/multi_travel_verified_spots_success.json @@ -0,0 +1,14 @@ +{ + "code": 100000, + "msg": "success", + "data": [ + { + "id": 901, + "name": "湖心亭" + }, + { + "id": "902", + "name": "观景台" + } + ] +} diff --git a/suixinkan_iosTests/Fixtures/operating_area_empty.json b/suixinkan_iosTests/Fixtures/operating_area_empty.json new file mode 100644 index 0000000..2f7b44f --- /dev/null +++ b/suixinkan_iosTests/Fixtures/operating_area_empty.json @@ -0,0 +1,8 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 0, + "list": [] + } +} diff --git a/suixinkan_iosTests/Fixtures/operating_area_success.json b/suixinkan_iosTests/Fixtures/operating_area_success.json new file mode 100644 index 0000000..80af1a9 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/operating_area_success.json @@ -0,0 +1,46 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 4, + "list": [ + { + "id": "101", + "name": "东门门店", + "business_map_area": [[[118.781, 32.041], [118.782, 32.041], [118.782, 32.042], [118.781, 32.041]]], + "status_text": "启用", + "type_text": "门店", + "audit_status_text": "通过" + }, + { + "id": 102, + "name": "西门门店", + "business_map_area": "{\"type\":\"Polygon\",\"coordinates\":[[[118.791,32.051],[118.792,32.051],[118.792,32.052],[118.791,32.051]]]}", + "status_text": "启用", + "type_text": "门店", + "audit_status_text": "通过" + }, + { + "id": 103, + "name": "北区", + "business_map_area": { + "type": "MultiPolygon", + "coordinates": [ + [[[118.801, 32.061], [118.802, 32.061], [118.802, 32.062], [118.801, 32.061]]] + ] + }, + "status_text": "启用", + "type_text": "区域", + "audit_status_text": "通过" + }, + { + "id": 104, + "name": "无效区域", + "business_map_area": [[118.811, 32.071], [118.812, 32.071]], + "status_text": "停用", + "type_text": "区域", + "audit_status_text": "待审核" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/order_v2_success.json b/suixinkan_iosTests/Fixtures/order_v2_success.json new file mode 100644 index 0000000..fe429ff --- /dev/null +++ b/suixinkan_iosTests/Fixtures/order_v2_success.json @@ -0,0 +1,83 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "2", + "list": [ + { + "photog_uid": "42", + "order_number": "ORDV2001", + "remark": "需要精修", + "scenic_area_id": "88", + "pay_time": "2026-05-23 04:40:00", + "complete_time": "", + "order_status": "20", + "order_status_name": "已支付", + "user_id": "7001", + "project_id": "5101", + "phone": "13800000000", + "order_amount": "299.50", + "actual_pay_amount": "299.50", + "actual_refund_amount": "0", + "refund_amount": "0", + "pay_type_name": "微信支付", + "pay_type": "1", + "project_name": "亲子跟拍", + "order_type": "19", + "order_type_label": "多点旅拍", + "deposit_pay_time": "", + "created_at": "2026-05-23 04:39:00", + "photo_travel": { + "order_photo_num": "9", + "order_video_num": "1", + "can_cancel_order": "1", + "need_check_in": "0", + "check_in_time": "", + "can_gift_retouch": "1", + "retouch_gift_time": "2026-05-24 04:40:00", + "retouch_gift_photo_num": "2", + "retouch_gift_video_num": "0" + }, + "multi_travel": { + "material_list": [ + { + "file_name": "spot-901.jpg", + "file_url": "https://cdn.example.com/order/spot-901.jpg", + "file_type": "1", + "file_size": "204800", + "cover_url": "" + } + ] + }, + "isNeedEdit": "1", + "is_refined": "1" + }, + { + "photog_uid": 43, + "order_number": "ORDV2002", + "remark": "", + "scenic_area_id": 88, + "pay_time": "2026-05-23 04:42:00", + "complete_time": "2026-05-23 05:00:00", + "order_status": 30, + "order_status_name": "已完成", + "user_id": 7002, + "project_id": 5102, + "phone": "13900000000", + "order_amount": "99.00", + "actual_pay_amount": "99.00", + "actual_refund_amount": "0", + "refund_amount": "0", + "pay_type_name": "余额支付", + "pay_type": 3, + "project_name": "押金体验", + "order_type": 4, + "order_type_label": "押金", + "deposit_pay_time": "2026-05-23 04:41:00", + "created_at": "2026-05-23 04:41:00", + "isNeedEdit": false, + "is_refined": 0 + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/real_name_info_success.json b/suixinkan_iosTests/Fixtures/real_name_info_success.json new file mode 100644 index 0000000..694b438 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/real_name_info_success.json @@ -0,0 +1,26 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "real_name_info": { + "real_name": "测试摄影师", + "id_card_no": "320100********0012", + "verified": "1", + "start_date": "2026-01-01", + "end_date": "2046-01-01", + "is_long_valid": "0", + "front_url": "https://cdn.example.com/front.jpg", + "back_url": "https://cdn.example.com/back.jpg", + "created_at": "2026-05-23 04:00:00", + "audit_status": "2", + "audit_status_text": "审核通过", + "audit_at": "2026-05-23 04:05:00", + "auditor_id": "7", + "auditor": { + "id": 7, + "name": "审核员" + }, + "reject_reason": "" + } + } +} diff --git a/suixinkan_iosTests/Fixtures/scenic_queue_home_success.json b/suixinkan_iosTests/Fixtures/scenic_queue_home_success.json new file mode 100644 index 0000000..aeb17c8 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/scenic_queue_home_success.json @@ -0,0 +1,63 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "stats": { + "type": "1" + }, + "list": { + "total": "2", + "page": "1", + "page_size": "20", + "list": [ + { + "id": "1001", + "queue_code": "A001", + "mobile": "13800000000", + "status": "1", + "status_text": "等待中", + "wait_min": "8", + "ahead_count": "0", + "is_called": "1", + "queue_ban_label": "限制排队7天", + "identity_tag": "打野摄影师", + "queue_time": "2026-05-23 03:05:00", + "queue_count_today": "2", + "uid": "7001", + "mark_as_photog": "0", + "mark_as_freelance_photog": "1", + "is_miss_requeue": "1", + "is_miss_requeue_text": "重排", + "created_at": "2026-05-23 03:05", + "called_at": "2026-05-23 03:06", + "expired_at": "", + "finished_at": "" + }, + { + "id": 1002, + "queue_code": "A002", + "mobile": "13900000000", + "status": 1, + "status_text": "等待中", + "wait_min": 10, + "ahead_count": 1, + "is_called": 0, + "queue_ban_label": "", + "identity_tag": "普通用户", + "queue_time": "05-23 03:07", + "queue_count_today": 1, + "uid": 7002, + "mark_as_photog": 1, + "mark_as_freelance_photog": 0, + "is_miss_requeue": 0, + "is_miss_requeue_text": "", + "created_at": "2026-05-23 03:07", + "called_at": "", + "expired_at": "", + "finished_at": "" + } + ] + }, + "time": "2026-05-23 03:08" + } +} diff --git a/suixinkan_iosTests/Fixtures/scenic_queue_qrcode_success.json b/suixinkan_iosTests/Fixtures/scenic_queue_qrcode_success.json new file mode 100644 index 0000000..2524204 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/scenic_queue_qrcode_success.json @@ -0,0 +1,7 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "qrcode_url": "https://cdn.example.com/queue/spot-9.png" + } +} diff --git a/suixinkan_iosTests/Fixtures/scenic_queue_setting_success.json b/suixinkan_iosTests/Fixtures/scenic_queue_setting_success.json new file mode 100644 index 0000000..8aa7576 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/scenic_queue_setting_success.json @@ -0,0 +1,40 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "exists": "1", + "setting": { + "id": "11", + "scenic_id": "88", + "scenic_spot_id": "9", + "scenic_spot_name": "东门打卡点", + "photo_estimate_min": "2", + "photo_estimate_sec": "30", + "first_notice_threshold_pos": "3", + "first_notice_sms_enabled": "1", + "first_notice_call_enabled": "1", + "second_notice_threshold_pos": "1", + "second_notice_sms_enabled": "0", + "second_notice_call_enabled": "1", + "countdown_broadcast_interval_sec": "15", + "countdown_readable_threshold_sec": "60", + "business_start_time": "08:00:00", + "business_end_time": "22:00:59", + "status": "1", + "remark": "自动叫号", + "voice_broadcasts": [ + { + "content": "A001请到拍摄点", + "sort_order": "1" + }, + { + "content": "请后续游客到等候区", + "sort_order": "2" + } + ], + "created_at": "2026-05-23 03:10", + "updated_at": "2026-05-23 03:11" + }, + "time": "2026-05-23 03:12" + } +} diff --git a/suixinkan_iosTests/Fixtures/scenic_queue_stats_success.json b/suixinkan_iosTests/Fixtures/scenic_queue_stats_success.json new file mode 100644 index 0000000..3f863dd --- /dev/null +++ b/suixinkan_iosTests/Fixtures/scenic_queue_stats_success.json @@ -0,0 +1,9 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "queue_count": "5", + "avg_wait_min": "12.5", + "time": "2026-05-23 03:10" + } +} diff --git a/suixinkan_iosTests/Fixtures/scenic_spot_list_all_success.json b/suixinkan_iosTests/Fixtures/scenic_spot_list_all_success.json new file mode 100644 index 0000000..64a5961 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/scenic_spot_list_all_success.json @@ -0,0 +1,21 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "2", + "list": [ + { + "id": "901", + "name": "湖心亭", + "status": "1", + "status_label": "启用" + }, + { + "id": 902, + "name": "观景台", + "status": 0, + "status_label": "停用" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/server_code_error.json b/suixinkan_iosTests/Fixtures/server_code_error.json new file mode 100644 index 0000000..bb80148 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/server_code_error.json @@ -0,0 +1,4 @@ +{ + "code": 200001, + "msg": "登录已过期" +} diff --git a/suixinkan_iosTests/Fixtures/statistics_daily_page1_total3.json b/suixinkan_iosTests/Fixtures/statistics_daily_page1_total3.json new file mode 100644 index 0000000..f346212 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/statistics_daily_page1_total3.json @@ -0,0 +1,16 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 3, + "data": [ + { + "date": "2026-05-23", + "order_count": "5", + "order_price": "1299.50", + "refund": "0", + "received": "1299.50" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/statistics_daily_page2_total3.json b/suixinkan_iosTests/Fixtures/statistics_daily_page2_total3.json new file mode 100644 index 0000000..4473677 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/statistics_daily_page2_total3.json @@ -0,0 +1,23 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 3, + "data": [ + { + "date": "2026-05-22", + "order_count": 3, + "order_price": "650.00", + "refund": "50.00", + "received": 600 + }, + { + "date": "2026-05-21", + "order_count": 2, + "order_price": "420.00", + "refund": "0", + "received": "420.00" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/statistics_summary_success.json b/suixinkan_iosTests/Fixtures/statistics_summary_success.json new file mode 100644 index 0000000..c06bf09 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/statistics_summary_success.json @@ -0,0 +1,11 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "order_amount_sum": "¥12,345.67", + "order_count": "18", + "order_price_avg": "685.87", + "received_amount_sum": "12000.00", + "refund_amount_sum": "345.67" + } +} diff --git a/suixinkan_iosTests/Fixtures/store_order_detail_success.json b/suixinkan_iosTests/Fixtures/store_order_detail_success.json new file mode 100644 index 0000000..a470726 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/store_order_detail_success.json @@ -0,0 +1,42 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "order_number": "ORDV2001", + "order_type": "19", + "order_type_label": "多点旅拍", + "created_at": "2026-05-23 04:39:00", + "order_status": "20", + "order_status_name": "已支付", + "actual_pay_amount": "299.50", + "actual_refund_amount": 0, + "pay_type_name": "微信支付", + "pay_time": "2026-05-23 04:40:00", + "complete_time": "", + "user_id": "7001", + "phone": "13800000000", + "project_id": "5101", + "project_name": "亲子跟拍", + "remark": "需要精修", + "multi_travel": { + "project_info": { + "settle_spot_num": "3", + "single_spot_material_num": 8, + "single_spot_photo_num": "2", + "single_spot_video_num": 1 + }, + "shooting_list": [ + { + "scenic_spot_id": "901", + "photog_uid": "42", + "scenic_spot_name": "东门", + "staff_name": "", + "photog_name": "张三", + "status": "2", + "start_avg": "4.8", + "start": 5 + } + ] + } + } +} diff --git a/suixinkan_iosTests/Fixtures/store_order_shooting_detail_success.json b/suixinkan_iosTests/Fixtures/store_order_shooting_detail_success.json new file mode 100644 index 0000000..f10353d --- /dev/null +++ b/suixinkan_iosTests/Fixtures/store_order_shooting_detail_success.json @@ -0,0 +1,44 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "scenic_spot_id": "901", + "scenic_spot_name": "湖心亭", + "order_type": "4", + "order_type_name": "押金订单", + "order_comment": { + "star_shooting": "5", + "star_retouching": "4", + "star_scenery": "5", + "star_service": "5", + "star_camera": "4", + "content": "拍摄体验很好", + "created_at": "2026-05-23 12:00:00" + }, + "material_list": [ + { + "file_name": "negative-video.mp4", + "file_url": "https://cdn.example.com/deposit/negative-video.mp4", + "file_type": "1", + "file_size": "10485760", + "cover_url": "https://cdn.example.com/deposit/negative-video.jpg" + }, + { + "file_name": "negative-photo.jpg", + "file_url": "https://cdn.example.com/deposit/negative-photo.jpg", + "file_type": 2, + "file_size": 204800, + "cover_url": "" + } + ], + "complete_list": [ + { + "file_name": "retouch.jpg", + "file_url": "https://cdn.example.com/deposit/retouch.jpg", + "file_type": "2", + "file_size": "409600", + "cover_url": "https://cdn.example.com/deposit/retouch-thumb.jpg" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/task_detail_success.json b/suixinkan_iosTests/Fixtures/task_detail_success.json new file mode 100644 index 0000000..fee0619 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/task_detail_success.json @@ -0,0 +1,38 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "id": "101", + "name": "游客精修任务", + "created_at": "2026-06-21 10:00:00", + "task_status": "2", + "task_status_label": "处理中", + "accepted_at": "2026-06-21 10:05:00", + "editor_name": "小李", + "editor_phone": "13900000000", + "urgent_hour": "24", + "photog_remark": "请优先处理亲子照片", + "order": { + "project_name": "亲子跟拍", + "order_number": "ORD-TASK-001", + "pay_time": "2026-06-21 09:55:00", + "user_phone": "13800000000" + }, + "task_result": [ + { + "id": 9101, + "photog_task_id": 101, + "user_id": 7001, + "file_name": "result.mp4", + "file_type": 1, + "file_url": "https://cdn.example.com/task/result.mp4", + "file_size": 4096, + "cover_url": "https://cdn.example.com/task/result-cover.jpg", + "duration": 12, + "remark": "", + "created_at": "2026-06-21 11:00:00", + "updated_at": "2026-06-21 11:00:00" + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/task_list_page2_success.json b/suixinkan_iosTests/Fixtures/task_list_page2_success.json new file mode 100644 index 0000000..b259f03 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/task_list_page2_success.json @@ -0,0 +1,26 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 3, + "list": [ + { + "id": 103, + "name": "补传照片", + "type": 1, + "order_number": "ORD-TASK-003", + "task_status": 3, + "status_name": "已完成", + "created_at": "2026-06-23 09:00:00", + "updated_at": "2026-06-23 09:30:00", + "photog_remark": "", + "editor_name": "小张", + "operate_time": "", + "order_user_nickname": "", + "order_user_phone": "", + "order_user_id": "", + "media": [] + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/task_list_success.json b/suixinkan_iosTests/Fixtures/task_list_success.json new file mode 100644 index 0000000..798e509 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/task_list_success.json @@ -0,0 +1,58 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": "2", + "list": [ + { + "id": "101", + "name": "游客精修任务", + "type": "1", + "order_number": "ORD-TASK-001", + "task_status": "2", + "status_name": "处理中", + "created_at": "2026-06-21 10:00:00", + "updated_at": "2026-06-21 10:30:00", + "photog_remark": "请优先处理亲子照片", + "editor_name": "小李", + "operate_time": "2026-06-21 10:31:00", + "order_user_nickname": "王女士", + "order_user_phone": "13800000000", + "order_user_id": "7001", + "media": [ + { + "id": "9001", + "photog_task_id": "101", + "user_id": "7001", + "file_name": "cover.jpg", + "file_type": "2", + "file_url": "https://cdn.example.com/task/cover.jpg", + "file_size": "2048", + "cover_url": "", + "duration": "0", + "remark": "", + "created_at": "2026-06-21 10:01:00", + "updated_at": "2026-06-21 10:01:00" + } + ] + }, + { + "id": 102, + "name": "短视频剪辑", + "type": 1, + "order_number": "ORD-TASK-002", + "task_status": 1, + "status_name": "待接收", + "created_at": "2026-06-22 09:00:00", + "updated_at": "2026-06-22 09:00:00", + "photog_remark": "", + "editor_name": "", + "operate_time": "", + "order_user_nickname": "", + "order_user_phone": "", + "order_user_id": "", + "media": [] + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/user_info_success.json b/suixinkan_iosTests/Fixtures/user_info_success.json new file mode 100644 index 0000000..db0b8d6 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/user_info_success.json @@ -0,0 +1,13 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "avatar": "https://example.com/avatar.jpg", + "real_name": "测试摄影师", + "phone": "13800000000", + "nickname": "跟拍小苏", + "role_name": "摄影师", + "status": 1, + "status_name": "已认证" + } +} diff --git a/suixinkan_iosTests/Fixtures/v9_login_multi_success.json b/suixinkan_iosTests/Fixtures/v9_login_multi_success.json new file mode 100644 index 0000000..67801e8 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/v9_login_multi_success.json @@ -0,0 +1,67 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "token": "person-temp-token", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "status": 1, + "status_label": "正常", + "app_role_id": 3, + "app_role_code": "scenic_admin", + "app_role_name": "景区管理员", + "app_role": { + "id": 3, + "legacy_role_id": 53, + "code": "scenic_admin", + "name": "景区管理员", + "notes": "", + "status": 1 + }, + "is_current": false + } + ], + "store_users": [ + { + "account_type": "store_user", + "id": 201, + "user_id": 201, + "store_user_id": 201, + "username": "store_admin", + "user_name": "store_admin", + "real_name": "张三", + "phone": "13800138000", + "avatar": "https://example.com/avatar.png", + "scenic_id": 10, + "scenic_name": "示例景区", + "store_id": 20, + "store_name": "示例门店", + "status": 1, + "status_label": "正常", + "app_role_id": 1, + "app_role_code": "store_admin", + "app_role_name": "店铺管理员", + "app_role": { + "id": 1, + "legacy_role_id": 46, + "code": "store_admin", + "name": "店铺管理员", + "notes": "", + "status": 1 + }, + "is_current": false + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/v9_login_single_scenic_success.json b/suixinkan_iosTests/Fixtures/v9_login_single_scenic_success.json new file mode 100644 index 0000000..bc123a9 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/v9_login_single_scenic_success.json @@ -0,0 +1,24 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "token": "person-temp-token", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "is_current": false + } + ], + "store_users": [] + } +} diff --git a/suixinkan_iosTests/Fixtures/v9_set_scenic_user_success.json b/suixinkan_iosTests/Fixtures/v9_set_scenic_user_success.json new file mode 100644 index 0000000..09c3413 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/v9_set_scenic_user_success.json @@ -0,0 +1,24 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "token": "business-token-scenic", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "is_current": true + } + ], + "store_users": [] + } +} diff --git a/suixinkan_iosTests/Fixtures/v9_set_store_user_success.json b/suixinkan_iosTests/Fixtures/v9_set_store_user_success.json new file mode 100644 index 0000000..0bdb72c --- /dev/null +++ b/suixinkan_iosTests/Fixtures/v9_set_store_user_success.json @@ -0,0 +1,39 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "token": "business-token-store", + "scenic_users": [], + "store_users": [ + { + "account_type": "store_user", + "id": 201, + "user_id": 201, + "store_user_id": 201, + "username": "store_admin", + "user_name": "store_admin", + "real_name": "张三", + "phone": "13800138000", + "avatar": "https://example.com/avatar.png", + "scenic_id": 10, + "scenic_name": "示例景区", + "store_id": 20, + "store_name": "示例门店", + "status": 1, + "status_label": "正常", + "app_role_id": 1, + "app_role_code": "store_admin", + "app_role_name": "店铺管理员", + "app_role": { + "id": 1, + "legacy_role_id": 46, + "code": "store_admin", + "name": "店铺管理员", + "notes": "", + "status": 1 + }, + "is_current": true + } + ] + } +} diff --git a/suixinkan_iosTests/Fixtures/writeoff_list_success.json b/suixinkan_iosTests/Fixtures/writeoff_list_success.json new file mode 100644 index 0000000..38363e3 --- /dev/null +++ b/suixinkan_iosTests/Fixtures/writeoff_list_success.json @@ -0,0 +1,19 @@ +{ + "code": 100000, + "msg": "success", + "data": { + "total": 1, + "list": [ + { + "order_number": "VERIFY001", + "order_verification_status": "待核销", + "pay_time": "2026-05-23 03:03", + "project_name": "核销套餐", + "user_phone": "13800000000", + "order_amount": "99.00", + "order_status_name": "已支付", + "order_verification_time": "" + } + ] + } +} diff --git a/suixinkan_iosTests/GlobalLoadingCenterTests.swift b/suixinkan_iosTests/GlobalLoadingCenterTests.swift new file mode 100644 index 0000000..839bdba --- /dev/null +++ b/suixinkan_iosTests/GlobalLoadingCenterTests.swift @@ -0,0 +1,113 @@ +// +// GlobalLoadingCenterTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 全局 Loading 命令中心测试,验证展示状态不会泄漏给业务页面使用。 +final class GlobalLoadingCenterTests: XCTestCase { + /// 测试多次 show/hide 会按引用计数控制最终显隐。 + func testShowHideUsesReferenceCount() { + let center = GlobalLoadingCenter() + + center.show(message: "加载账号") + center.show(message: "加载权限") + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2)) + + center.hide() + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1)) + + center.hide() + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试 hide 调用次数超过 show 时不会产生负数计数。 + func testHideNeverDropsReferenceCountBelowZero() { + let center = GlobalLoadingCenter() + + center.hide() + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试更新文案只影响当前展示内容,不改变引用计数。 + func testUpdateMessageOnlyChangesVisibleMessage() { + let center = GlobalLoadingCenter() + + center.updateMessage("不会展示") + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + + center.show(message: "加载中") + center.updateMessage("即将完成") + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1)) + } + + /// 测试 withLoading 成功完成后自动隐藏 Loading。 + func testWithLoadingHidesAfterSuccess() async throws { + let center = GlobalLoadingCenter() + + let value = try await center.withLoading(message: "提交中") { + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1)) + return 42 + } + + XCTAssertEqual(value, 42) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试 withLoading 抛错时也会自动隐藏 Loading。 + func testWithLoadingHidesAfterFailure() async { + let center = GlobalLoadingCenter() + + do { + _ = try await center.withLoading(message: "提交中") { + throw TestFailure.expected + } as Int + XCTFail("withLoading 应该向外抛出业务错误") + } catch { + XCTAssertEqual(error as? TestFailure, .expected) + } + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试 withOptionalLoading 关闭时不会改变展示状态。 + func testWithOptionalLoadingDisabledDoesNotChangeState() async throws { + let center = GlobalLoadingCenter() + + let value = try await center.withOptionalLoading(false, message: "不展示") { + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + return "done" + } + + XCTAssertEqual(value, "done") + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + } + + /// 测试使用 Loading 不需要读取可观察展示状态,业务侧只能拿到命令中心类型。 + func testBusinessCodeOnlyDependsOnCommandCenter() { + let center = GlobalLoadingCenter() + + issueLoadingCommand(center) + + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1)) + center.hide() + } + + /// 模拟业务代码只接收命令中心并发出展示指令。 + private func issueLoadingCommand(_ center: GlobalLoadingCenter) { + center.show(message: "加载中") + } + + /// 测试用错误类型。 + private enum TestFailure: Error, Equatable { + case expected + } +} diff --git a/suixinkan_iosTests/HomeCommonMenuStoreTests.swift b/suixinkan_iosTests/HomeCommonMenuStoreTests.swift new file mode 100644 index 0000000..4f9a707 --- /dev/null +++ b/suixinkan_iosTests/HomeCommonMenuStoreTests.swift @@ -0,0 +1,80 @@ +// +// HomeCommonMenuStoreTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +/// 首页常用应用存储测试,覆盖默认值、过滤和别名去重。 +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("project_edit") + ] + + let uris = store.load(menuItems: menus) + + XCTAssertEqual(uris, ["task_management_editor", "project_edit"]) + } + + /// 测试添加常用应用时会按同义 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: ["project_edit", "pm_manager", "location_report"]) + + XCTAssertEqual(uris, ["pm_manager", "location_report"]) + } + + /// 创建测试菜单实体。 + private func menu(_ uri: String) -> HomeMenuItem { + HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil) + } + + /// 创建独立 UserDefaults,避免测试污染真实 App 偏好。 + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "suixinkan.home.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} diff --git a/suixinkan_iosTests/HomeMenuRouterTests.swift b/suixinkan_iosTests/HomeMenuRouterTests.swift new file mode 100644 index 0000000..b869347 --- /dev/null +++ b/suixinkan_iosTests/HomeMenuRouterTests.swift @@ -0,0 +1,228 @@ +// +// HomeMenuRouterTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +/// 首页菜单路由测试,覆盖 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)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "wallet", title: ""), .destination(.wallet)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_collection", title: ""), .destination(.paymentCollection)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_qr", title: ""), .destination(.paymentCollection)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_code", title: ""), .destination(.paymentCollection)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply", title: ""), .destination(.permissionApply)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "permission_apply_status", title: ""), .destination(.permissionApplyStatus)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicapplication", title: ""), .destination(.scenicApplication)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management", title: ""), .destination(.taskManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_management_editor", title: ""), .destination(.taskManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "task_create", title: ""), .destination(.taskCreate)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_management", title: ""), .destination(.cloudStorage)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "cloud_storage_transit", title: ""), .destination(.cloudStorageTransit)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "asset_management", title: ""), .destination(.materialLibrary)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "material_upload", title: ""), .destination(.materialUpload)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_trailer", title: ""), .destination(.albumTrailer)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report_history", title: ""), .destination(.locationReportHistory)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "project_edit", title: ""), .destination(.projectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_invite", title: ""), .destination(.photographerInvite)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_detail", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "withdrawal_audit", title: ""), .destination(.withdrawalAudit)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.scenicSettlement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.scenicSettlementReview)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification)) + } + + /// 测试 Tab 路由仍集中在 HomeMenuRouter。 + func testTabRoutesStayCentralized() { + XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .orders(.storeOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .orders(.storeOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orders(.verificationOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics)) + } + + /// 测试飞控相关入口属于已知不支持范围。 + func testFlightControlRoutesAreKnownUnsupportedScope() { + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "fly", title: ""), + .unsupported(uri: "fly", title: "飞行管理", reason: "DJI/飞控模块已明确不纳入 iOS 迁移和后续开发范围。") + ) + } + + /// 测试未知 URI 会显式进入 placeholder。 + func testUnknownRouteIsExplicitPlaceholder() { + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "android_only_feature", title: "Android 专项"), + .placeholder(uri: "android_only_feature", title: "Android 专项") + ) + } + + /// 测试 canonicalURI 会使用当前权限可用的同义 URI。 + func testCanonicalURIUsesAvailableAlias() { + let available: Set = ["task_management_editor", "photographer_invite", "project_edit", "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), "project_edit") + XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm_manager", 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: "project_edit"), "pm") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "pm_manager"), "pm_manager") + 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: "/scenic-queue"), "queue_management") + XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "queue_management"), "queue_management") + 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) + } + + #if DEBUG + /// 测试 DEBUG 菜单列表不依赖权限,包含已迁移和未迁移入口。 + func testDebugAllMenuItemsExposeKnownMenusWithoutPermissions() { + let items = HomeMenuRouter.debugAllMenuItems() + let uris = items.map(\.uri) + + XCTAssertTrue(uris.contains("album_list")) + XCTAssertTrue(uris.contains("wallet")) + XCTAssertTrue(uris.contains("sample_management")) + XCTAssertTrue(uris.contains("checkin_points")) + XCTAssertTrue(uris.contains("location_report")) + XCTAssertTrue(uris.contains("pm")) + XCTAssertTrue(uris.contains("pm_manager")) + XCTAssertTrue(uris.contains("schedule_management")) + XCTAssertTrue(uris.contains("registration_invitation")) + XCTAssertTrue(uris.contains("invite_record")) + XCTAssertTrue(uris.contains("deposit_order")) + XCTAssertTrue(uris.contains("deposit_order_shooting_info")) + XCTAssertTrue(uris.contains("message_center")) + XCTAssertTrue(uris.contains("/scenic-queue")) + XCTAssertTrue(uris.contains("queue_management")) + XCTAssertTrue(uris.contains("live_stream_management")) + XCTAssertTrue(uris.contains("live_album")) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum)) + } + #endif + + /// 创建角色权限测试实体。 + private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse { + RolePermissionResponse( + role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions), + scenic: [] + ) + } + + /// 创建权限节点测试实体。 + private func permission(id: Int, name: String, uri: String, children: [PermissionItem] = []) -> PermissionItem { + PermissionItem(id: id, name: name, uri: uri, children: children) + } +} diff --git a/suixinkan_iosTests/HomeViewModelTests.swift b/suixinkan_iosTests/HomeViewModelTests.swift new file mode 100644 index 0000000..2a6d8aa --- /dev/null +++ b/suixinkan_iosTests/HomeViewModelTests.swift @@ -0,0 +1,111 @@ +// +// HomeViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +@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), ["pm", "deposit_order_detail", "pm_manager", "payment_code"]) + } + + /// 测试菜单按旧工程 preferred order 排序。 + func testBuildMenusSortsByPreferredOrder() { + let viewModel = HomeViewModel() + let permissions = [ + rolePermission(roleId: 1, permissions: [ + permission(id: 1, name: "消息中心", uri: "message_center"), + permission(id: 2, name: "空间设置", uri: "space_settings"), + permission(id: 3, name: "钱包", uri: "wallet") + ]) + ] + + viewModel.buildMenus(from: permissions, currentRoleId: 1) + + XCTAssertEqual(viewModel.menuItems.map(\.uri), ["space_settings", "wallet", "message_center"]) + } + + /// 创建角色权限测试实体。 + private func rolePermission(roleId: Int, permissions: [PermissionItem]) -> RolePermissionResponse { + RolePermissionResponse( + role: RoleInfo(id: roleId, name: "角色\(roleId)", permission: permissions), + scenic: [] + ) + } + + /// 创建权限节点测试实体。 + private func permission(id: Int, name: String, uri: String, children: [PermissionItem] = []) -> PermissionItem { + PermissionItem(id: id, name: name, uri: uri, children: children) + } +} diff --git a/suixinkan_iosTests/Invite/InviteTests.swift b/suixinkan_iosTests/Invite/InviteTests.swift new file mode 100644 index 0000000..a1724de --- /dev/null +++ b/suixinkan_iosTests/Invite/InviteTests.swift @@ -0,0 +1,154 @@ +// +// InviteTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 邀请 API 和 ViewModel 测试。 +final class InviteTests: XCTestCase { + /// 测试邀请 API path 和分页参数。 + func testInviteAPIUsesExpectedRequests() async throws { + let session = InviteRecordingURLSession(responses: [Self.inviteInfoResponse, Self.inviteUsersResponse]) + let api = InviteAPI(client: APIClient(session: session)) + + let info = try await api.inviteInfo() + let users = try await api.inviteUserList(page: 0, pageSize: 0) + + XCTAssertEqual(info.inviteCode, "ABC123") + XCTAssertEqual(users.first?.id, 1) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/invite-info", + "/api/yf-handset-app/photog/invite/user-list" + ]) + XCTAssertEqual(inviteQueryItems(from: session.requests[1])["page"], "1") + XCTAssertEqual(inviteQueryItems(from: session.requests[1])["page_size"], "1") + } + + /// 测试邀请页加载信息并生成二维码。 + func testInviteViewModelLoadsInfoAndGeneratesQRCode() async { + let api = FakeInviteService() + let viewModel = PhotographerInviteViewModel() + + await viewModel.reload(api: api) + + XCTAssertEqual(viewModel.inviteCode, "ABC123") + XCTAssertEqual(viewModel.rules, ["规则1"]) + XCTAssertNotNil(viewModel.qrImage) + } + + /// 测试邀请记录加载邀请用户和钱包汇总。 + func testInviteRecordLoadsInviteRowsAndSummary() async { + let inviteAPI = FakeInviteService() + let walletAPI = FakeWalletService() + let viewModel = InviteRecordViewModel() + + await viewModel.reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true) + + XCTAssertEqual(viewModel.displayRows.first?.title, "摄影师") + XCTAssertEqual(viewModel.totalRewardText, "¥ 100.00") + XCTAssertEqual(walletAPI.summaryTypes, [2]) + } + + /// 测试奖励分段调用钱包收益明细。 + func testInviteRecordRewardTabLoadsWalletDetails() async { + let inviteAPI = FakeInviteService() + let walletAPI = FakeWalletService() + let viewModel = InviteRecordViewModel() + + await viewModel.selectTab(.reward, inviteAPI: inviteAPI, walletAPI: walletAPI) + + XCTAssertEqual(walletAPI.earningPages, [1]) + XCTAssertNil(viewModel.errorMessage, viewModel.errorMessage ?? "") + XCTAssertEqual(viewModel.displayRows.first?.amount, "8.00") + } + + private static let inviteInfoResponse = Data(#"{"code":100000,"message":"ok","data":{"enable_invite":"1","invite_code":"ABC123","invite_url":"https://invite.example.com","description":["规则1"]}}"#.utf8) + private static let inviteUsersResponse = Data(#"{"code":100000,"message":"ok","data":[{"id":"1","real_name":"摄影师","phone":"13800000000","avatar":"","created_at":"2026-06-24","invite_level":"1"}]}"#.utf8) +} + +/// 邀请 API 测试 URLSession。 +private final class InviteRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化测试 Session。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.removeFirst() + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +/// 邀请服务测试替身。 +@MainActor +private final class FakeInviteService: InviteServing { + func inviteInfo() async throws -> InviteInfoResponse { + try JSONDecoder().decode(InviteInfoResponse.self, from: Data(#"{"enable_invite":"1","invite_code":"ABC123","invite_url":"https://invite.example.com","description":["规则1"]}"#.utf8)) + } + + func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem] { + [try JSONDecoder().decode(InviteUserItem.self, from: Data(#"{"id":"1","real_name":"摄影师","phone":"13800000000","avatar":"","created_at":"2026-06-24","invite_level":"1"}"#.utf8))] + } +} + +/// 钱包服务测试替身。 +@MainActor +private final class FakeWalletService: WalletServing { + var summaryTypes: [Int] = [] + var earningPages: [Int] = [] + + func walletSummary(type: Int) async throws -> WalletSummaryResponse { + summaryTypes.append(type) + return WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "20.00", amountWithdrawable: "50.00") + } + + func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse { + earningPages.append(page) + let item = WalletEarningDetailItem( + id: 1, + amount: "8.00", + type: 2, + typeLabel: "邀请奖励", + orderNumberSuffix: "0001", + createdAt: "2026-06-24" + ) + return WalletEarningDetailResponse( + totalAmount: "8.00", + total: 1, + list: [WalletEarningDetailGroup(date: "2026-06-24", dayAmount: "8.00", items: [item])] + ) + } + + func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { WalletWithdrawListResponse() } + func withdrawInfo() async throws -> WithdrawInfoResponse { try JSONDecoder().decode(WithdrawInfoResponse.self, from: Data("{}".utf8)) } + func withdrawSendSms() async throws {} + func withdrawApply(amount: String, smsCode: String) async throws {} + func bankCardInfo() async throws -> BankCardInfoResponse { try JSONDecoder().decode(BankCardInfoResponse.self, from: Data("{}".utf8)) } + func bankList() async throws -> BankListResponse { BankListResponse(banks: []) } + func areas() async throws -> [AreaNode] { [] } + func bankCardVerifyCode() async throws {} + func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {} + func pointOverview(staffId: Int) async throws -> PointOverviewResponse { try JSONDecoder().decode(PointOverviewResponse.self, from: Data("{}".utf8)) } + func pointWithdrawApply(points: Int, remark: String) async throws {} + func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { PointWithdrawListResponse(total: 0, list: []) } +} + +/// 从请求中提取 query 字典。 +private func inviteQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkan_iosTests/Live/LiveAPITests.swift b/suixinkan_iosTests/Live/LiveAPITests.swift new file mode 100644 index 0000000..556204b --- /dev/null +++ b/suixinkan_iosTests/Live/LiveAPITests.swift @@ -0,0 +1,168 @@ +// +// LiveAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 直播 API 测试,覆盖直播管理和直播相册接口编码。 +final class LiveAPITests: XCTestCase { + func testLiveListAndDetailDecodeFixturesAndUseExpectedQueries() async throws { + let listSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_list_success")) + let listAPI = LiveAPI(client: APIClient(session: listSession)) + + let list = try await listAPI.liveList(scenicId: 88, page: 0, pageSize: 0) + + XCTAssertEqual(list.total, 2) + XCTAssertEqual(list.page, 1) + XCTAssertEqual(list.pageSize, 1) + XCTAssertEqual(list.items.first?.id, 8101) + XCTAssertEqual(list.items.first?.manualPushMode, 2) + let listRequest = try XCTUnwrap(listSession.requests.first) + XCTAssertEqual(listRequest.httpMethod, "GET") + XCTAssertEqual(listRequest.url?.path, "/api/app/manual-live/list") + XCTAssertEqual(liveQueryItems(from: listRequest)["scenic_id"], "88") + XCTAssertEqual(liveQueryItems(from: listRequest)["page"], "1") + XCTAssertEqual(liveQueryItems(from: listRequest)["page_size"], "1") + + let detailSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_detail_success")) + let detailAPI = LiveAPI(client: APIClient(session: detailSession)) + + let detail = try await detailAPI.liveDetail(liveId: 8101) + + XCTAssertEqual(detail.id, 8101) + XCTAssertEqual(detail.pushUrl, "rtmp://push.example.com/live/8101") + XCTAssertEqual(detail.status, 2) + XCTAssertEqual(detail.duration, 3600) + let detailRequest = try XCTUnwrap(detailSession.requests.first) + XCTAssertEqual(detailRequest.url?.path, "/api/app/manual-live/detail") + XCTAssertEqual(liveQueryItems(from: detailRequest)["live_id"], "8101") + } + + func testLiveCreateAndControlsUseExpectedBodies() async throws { + let createSession = LiveRecordingURLSession(responses: Array(repeating: try TestFixture.data(named: "empty_success"), count: 5)) + let api = LiveAPI(client: APIClient(session: createSession)) + + try await api.liveCreate(LiveCreateRequest(scenicId: "88", title: "景区晨间直播", coverImg: "https://cdn.example.com/cover.jpg")) + + var request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/create") + var json = try liveJSONBody(from: request) + XCTAssertEqual(json["scenic_id"] as? String, "88") + XCTAssertEqual(json["title"] as? String, "景区晨间直播") + XCTAssertEqual(json["cover_img"] as? String, "https://cdn.example.com/cover.jpg") + + try await api.liveStart(liveId: 8101) + request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/start") + XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101) + + try await api.liveStop(liveId: 8101) + request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/stop") + XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101) + + try await api.liveFinish(liveId: 8101) + request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/finish") + XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101) + + try await api.liveSetPushMode(liveId: 8101, mode: 2) + request = try XCTUnwrap(createSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/manual-live/set-push-mode") + json = try liveJSONBody(from: request) + XCTAssertEqual(json["live_id"] as? Int, 8101) + XCTAssertEqual(json["manual_push_mode"] as? Int, 2) + } + + func testLiveAlbumAPIsUseExpectedRequests() async throws { + let listSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_album_list_success")) + let api = LiveAPI(client: APIClient(session: listSession)) + + let response = try await api.liveAlbumList(scenicId: 88, startTime: "2026-05-01", endTime: "2026-05-23", page: 0, pageSize: 0) + + XCTAssertEqual(response.total, 2) + XCTAssertEqual(response.items.first?.id, 6101) + XCTAssertEqual(response.items.first?.items.last?.previewURL, "https://cdn.example.com/alive/video-cover.jpg") + var request = try XCTUnwrap(listSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/folders") + var query = liveQueryItems(from: request) + XCTAssertEqual(query["scenic_id"], "88") + XCTAssertEqual(query["start_time"], "2026-05-01") + XCTAssertEqual(query["end_time"], "2026-05-23") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + + let mutationSession = LiveRecordingURLSession(data: try TestFixture.data(named: "empty_success")) + let mutationAPI = LiveAPI(client: APIClient(session: mutationSession)) + try await mutationAPI.liveAlbumCreateFolder( + LiveAlbumCreateFolderRequest( + scenicId: "88", + name: "五一直播相册", + items: [LiveAlbumCreateFileItem(url: "https://cdn.example.com/a.jpg", type: 1, size: 12, coverImg: nil)] + ) + ) + request = try XCTUnwrap(mutationSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/create-folder") + var json = try liveJSONBody(from: request) + XCTAssertEqual(json["scenic_id"] as? String, "88") + XCTAssertEqual((json["items"] as? [[String: Any]])?.first?["url"] as? String, "https://cdn.example.com/a.jpg") + + try await mutationAPI.liveAlbumDeleteFolder(folderId: 6101) + request = try XCTUnwrap(mutationSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/delete-folder") + XCTAssertEqual(try liveJSONBody(from: request)["folder_id"] as? Int, 6101) + + let detailSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_album_folder_detail_success")) + let detailAPI = LiveAPI(client: APIClient(session: detailSession)) + let folder = try await detailAPI.liveAlbumFolderDetail(folderId: 6101) + XCTAssertEqual(folder.items.count, 2) + request = try XCTUnwrap(detailSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/folder-detail") + XCTAssertEqual(liveQueryItems(from: request)["folder_id"], "6101") + + try await mutationAPI.liveAlbumDeleteFiles(folderId: 6101, fileIds: [7101, 7102]) + request = try XCTUnwrap(mutationSession.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/view-album/delete-files") + json = try liveJSONBody(from: request) + XCTAssertEqual(json["folder_id"] as? Int, 6101) + XCTAssertEqual(json["file_ids"] as? [Int], [7101, 7102]) + } +} + +private final class LiveRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + init(data: Data) { + responses = [data] + } + + init(responses: [Data]) { + self.responses = responses + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? try TestFixture.data(named: "empty_success") : responses.removeFirst() + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +private func liveQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return [:] } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +private func liveJSONBody(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} diff --git a/suixinkan_iosTests/Live/LiveViewModelTests.swift b/suixinkan_iosTests/Live/LiveViewModelTests.swift new file mode 100644 index 0000000..0a171ab --- /dev/null +++ b/suixinkan_iosTests/Live/LiveViewModelTests.swift @@ -0,0 +1,588 @@ +// +// LiveViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 直播 ViewModel 测试,覆盖列表、详情、相册和上传流程。 +final class LiveViewModelTests: XCTestCase { + func testLiveManagementReloadLoadMoreAndMissingScenicReset() async { + let api = LiveMock() + api.liveListResponses = [ + LiveListResponse(items: [live(id: 1, status: 2), live(id: 2, status: 3)], total: 3, page: 1, pageSize: 2), + LiveListResponse(items: [live(id: 3, status: 1)], total: 3, page: 2, pageSize: 2) + ] + let viewModel = LiveManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88) + await viewModel.loadMore(api: api, scenicId: 88) + await viewModel.loadMore(api: api, scenicId: 88) + + XCTAssertEqual(api.liveListRequests.map(\.page), [1, 2]) + XCTAssertEqual(viewModel.items.map(\.id), [1, 2, 3]) + XCTAssertEqual(viewModel.liveRunningCount, 1) + XCTAssertEqual(viewModel.liveFinishedCount, 1) + XCTAssertFalse(viewModel.hasMore) + + await viewModel.reload(api: api, scenicId: nil) + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertNil(viewModel.detail) + XCTAssertFalse(viewModel.hasMore) + } + + func testLiveManagementReloadFailureAndDetailFailureClearStaleState() async { + let api = LiveMock() + api.liveListResponses = [LiveListResponse(items: [live(id: 1)], total: 1)] + api.liveDetailResponses = [live(id: 1, title: "旧详情")] + let viewModel = LiveManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88) + await viewModel.loadDetail(api: api, liveId: 1) + + XCTAssertEqual(viewModel.items.map(\.id), [1]) + XCTAssertEqual(viewModel.detail?.title, "旧详情") + + api.liveListError = LiveTestError.sample + await viewModel.reload(api: api, scenicId: 88) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertNil(viewModel.detail) + XCTAssertFalse(viewModel.hasMore) + XCTAssertEqual(viewModel.errorMessage, "测试错误") + + api.liveListError = nil + api.liveDetailError = LiveTestError.sample + await viewModel.loadDetail(api: api, liveId: 1) + XCTAssertNil(viewModel.detail) + } + + func testLiveManagementActionsRefreshAfterSuccess() async throws { + let api = LiveMock() + api.liveListResponses = [ + LiveListResponse(items: [live(id: 1, status: 1)], total: 1), + LiveListResponse(items: [live(id: 1, status: 2)], total: 1), + LiveListResponse(items: [live(id: 1, status: 3)], total: 1), + LiveListResponse(items: [live(id: 4, status: 1)], total: 1) + ] + let viewModel = LiveManagementViewModel() + await viewModel.reload(api: api, scenicId: 88) + + try await viewModel.control(api: api, item: live(id: 1, status: 1), scenicId: 88) + XCTAssertEqual(api.startIds, [1]) + XCTAssertEqual(viewModel.items.first?.status, 2) + + try await viewModel.finish(api: api, item: live(id: 1, status: 2), scenicId: 88) + XCTAssertEqual(api.finishIds, [1]) + XCTAssertEqual(viewModel.items.first?.status, 3) + + try await viewModel.create(api: api, scenicId: 88, title: " 新直播 ", coverURL: " https://cdn.example.com/c.jpg ") + XCTAssertEqual(api.createRequests.first?.title, "新直播") + XCTAssertEqual(api.createRequests.first?.coverImg, "https://cdn.example.com/c.jpg") + XCTAssertEqual(viewModel.items.first?.id, 4) + } + + func testLiveDetailActionsRefreshDetail() async throws { + let api = LiveMock() + api.liveDetailResponses = [ + live(id: 1, status: 2, manualPushMode: 1), + live(id: 1, status: 3, manualPushMode: 1), + live(id: 1, status: 3, manualPushMode: 2) + ] + let viewModel = LiveDetailViewModel(detail: live(id: 1, status: 1, manualPushMode: 1)) + + try await viewModel.control(api: api) + XCTAssertEqual(api.startIds, [1]) + XCTAssertEqual(viewModel.detail.status, 2) + + try await viewModel.finish(api: api) + XCTAssertEqual(api.finishIds, [1]) + XCTAssertEqual(viewModel.detail.status, 3) + + try await viewModel.setPushMode(api: api, mode: 2) + XCTAssertEqual(api.pushModeRequests.map(\.mode), [2]) + XCTAssertEqual(viewModel.detail.manualPushMode, 2) + } + + func testLiveAlbumDateValidationReloadLoadMoreAndFailureBehavior() async { + let api = LiveMock() + api.albumListResponses = [ + LiveAlbumFolderListResponse(items: [folder(id: 1)], page: 1, pageSize: 1, total: 2), + LiveAlbumFolderListResponse(items: [folder(id: 2)], page: 2, pageSize: 1, total: 2) + ] + let viewModel = LiveAlbumViewModel() + + XCTAssertNoThrow(try viewModel.setStartDate(date("2026-05-01"))) + XCTAssertNoThrow(try viewModel.setEndDate(date("2026-05-23"))) + await viewModel.reload(api: api, scenicId: 88) + await viewModel.loadMore(api: api, scenicId: 88) + + XCTAssertEqual(api.albumListRequests.map(\.page), [1, 2]) + XCTAssertEqual(api.albumListRequests.first?.startTime, "2026-05-01") + XCTAssertEqual(api.albumListRequests.first?.endTime, "2026-05-23") + XCTAssertEqual(viewModel.folders.map(\.id), [1, 2]) + XCTAssertFalse(viewModel.hasMore) + XCTAssertThrowsError(try viewModel.setStartDate(date("2026-06-01"))) + + api.albumListError = LiveTestError.sample + await viewModel.reload(api: api, scenicId: 88) + XCTAssertTrue(viewModel.folders.isEmpty) + XCTAssertFalse(viewModel.hasMore) + + await viewModel.reload(api: api, scenicId: nil) + XCTAssertTrue(viewModel.folders.isEmpty) + } + + func testLiveAlbumCreateUploadsFilesAndClearsForm() async throws { + let api = LiveMock() + let uploader = LiveUploadMock() + let uploadedURLs = ["https://cdn.example.com/live_albums/a.jpg", "https://cdn.example.com/live_albums/b.mp4"] + uploader.urls = uploadedURLs + let viewModel = LiveAlbumCreateViewModel() + viewModel.name = " 五一相册 " + viewModel.addLocalFiles([ + LiveAlbumLocalUploadFile(data: Data([1, 2, 3]), fileName: "a.jpg", fileType: 1), + LiveAlbumLocalUploadFile(data: Data([4, 5, 6]), fileName: "b.mp4", fileType: 2) + ]) + + try await viewModel.submit(scenicId: 88, api: api, uploader: uploader) + + XCTAssertEqual(uploader.uploads.map(\.fileName), ["a.jpg", "b.mp4"]) + let request = try XCTUnwrap(api.albumCreateRequests.first) + XCTAssertEqual(request.scenicId, "88") + XCTAssertEqual(request.name, "五一相册") + XCTAssertEqual(request.items.map(\.url), uploadedURLs) + XCTAssertEqual(request.items.map(\.type), [1, 2]) + XCTAssertTrue(viewModel.name.isEmpty) + XCTAssertTrue(viewModel.localFiles.isEmpty) + XCTAssertEqual(viewModel.uploadProgress, 100) + } + + func testLiveAlbumCreateFailureKeepsForm() async { + let api = LiveMock() + let uploader = LiveUploadMock() + uploader.error = LiveTestError.sample + let viewModel = LiveAlbumCreateViewModel() + viewModel.name = "相册" + viewModel.addLocalFiles([LiveAlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 1)]) + + await XCTAssertThrowsErrorAsync(try await viewModel.submit(scenicId: 88, api: api, uploader: uploader)) + + XCTAssertEqual(viewModel.name, "相册") + XCTAssertEqual(viewModel.localFiles.count, 1) + XCTAssertTrue(api.albumCreateRequests.isEmpty) + } + + func testLiveAlbumPreviewLoadAndDeleteRefresh() async throws { + let api = LiveMock() + api.albumDetailResponses = [ + folder(id: 1, items: [albumFile(id: 11), albumFile(id: 12)]), + folder(id: 1, items: [albumFile(id: 12)]) + ] + let viewModel = LiveAlbumPreviewViewModel(folderId: 1, startIndex: 0, summary: nil) + + await viewModel.load(api: api) + XCTAssertEqual(viewModel.files.map(\.id), [11, 12]) + + try await viewModel.deleteCurrentFile(api: api) + XCTAssertEqual(api.albumDeleteFileRequests.first?.fileIds, [11]) + XCTAssertEqual(viewModel.files.map(\.id), [12]) + } + + func testLiveEntityDecodesPlaybackURLsAndResolverExcludesRTMP() throws { + let json = """ + { + "id": "1", + "title": "播放测试", + "cover_img": "https://cdn.example.com/c.jpg", + "push_url": "rtmp://push.example.com/live/1", + "play_url": "rtmp://pull.example.com/live/1", + "pull_url": "https://cdn.example.com/live/1.flv", + "hls_url": "https://cdn.example.com/live/1.m3u8", + "live_url": "https://cdn.example.com/live/1.mp4" + } + """.data(using: .utf8)! + + let live = try JSONDecoder().decode(LiveEntity.self, from: json) + + XCTAssertEqual(live.playUrl, "rtmp://pull.example.com/live/1") + XCTAssertEqual(live.pullUrl, "https://cdn.example.com/live/1.flv") + XCTAssertEqual(live.hlsUrl, "https://cdn.example.com/live/1.m3u8") + XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pushUrl)) + XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.playUrl)) + XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pullUrl)) + XCTAssertEqual(LivePlaybackURLResolver.playableURL(from: live)?.absoluteString, "https://cdn.example.com/live/1.m3u8") + } + + func testLivePlaybackViewModelStateTransitions() { + let viewModel = LivePlaybackViewModel(urlString: "https://cdn.example.com/live/1.m3u8") + + XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8") + XCTAssertFalse(viewModel.isPlaying) + + viewModel.play() + XCTAssertTrue(viewModel.isPlaying) + XCTAssertNotNil(viewModel.player) + + viewModel.pause() + XCTAssertFalse(viewModel.isPlaying) + + viewModel.release() + XCTAssertNil(viewModel.player) + XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8") + + viewModel.load(urlString: "rtmp://push.example.com/live/1") + XCTAssertNil(viewModel.playableURL) + XCTAssertEqual(viewModel.errorMessage, "暂无可播放地址") + } + + func testLivePushReadinessPermissionsNetworkAndUnsupportedAdapter() async { + let permissions = LivePermissionMock(camera: .granted, microphone: .granted) + let network = LiveNetworkMock(state: .wifi) + let adapter = LivePushAdapterMock(available: false) + let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter) + + viewModel.configure(pushURL: "rtmp://push.example.com/live/1") + viewModel.startMonitoring() + await viewModel.refreshPermissions() + + XCTAssertEqual(viewModel.cameraPermission, .granted) + XCTAssertEqual(viewModel.microphonePermission, .granted) + XCTAssertEqual(viewModel.networkState, .wifi) + XCTAssertEqual(viewModel.sdkStatusText, "未接入真推流 SDK") + + await XCTAssertThrowsErrorAsync(try await viewModel.prepare()) + XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription) + } + + func testLivePushReadinessDiagnosticsDoNotPrepareUnsupportedAdapter() async { + let permissions = LivePermissionMock(camera: .granted, microphone: .granted) + let network = LiveNetworkMock(state: .wifi) + let adapter = LivePushAdapterMock(available: false) + let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter) + + viewModel.configure(pushURL: "rtmp://push.example.com/live/1") + viewModel.startMonitoring() + await viewModel.refreshPermissions() + + XCTAssertThrowsError(try viewModel.runDiagnostics()) + XCTAssertFalse(adapter.prepareCalled) + XCTAssertFalse(adapter.startCalled) + XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription) + } + + func testLivePushReadinessBlocksDeniedPermissionAndUnavailableNetwork() async { + let permissions = LivePermissionMock(camera: .denied, microphone: .granted) + let network = LiveNetworkMock(state: .unavailable) + let adapter = LivePushAdapterMock(available: true) + let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter) + + viewModel.configure(pushURL: "rtmp://push.example.com/live/1") + viewModel.startMonitoring() + await viewModel.refreshPermissions() + + await XCTAssertThrowsErrorAsync(try await viewModel.prepare()) + XCTAssertFalse(adapter.prepareCalled) + + let missingURLViewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter) + missingURLViewModel.configure(pushURL: "") + await XCTAssertThrowsErrorAsync(try await missingURLViewModel.prepare()) + XCTAssertEqual(missingURLViewModel.errorMessage, LivePushReadinessError.missingPushURL.localizedDescription) + } + + /// 测试播放地址解析会跳过 RTMP/FLV 并优先选择可用 HTTP 地址。 + func testLivePlaybackResolverCandidatePriority() { + let live = LiveEntity( + playUrl: "rtmp://pull.example.com/live/1", + pullUrl: "https://cdn.example.com/live/1.flv", + hlsUrl: "https://cdn.example.com/live/1.m3u8", + liveUrl: "https://cdn.example.com/live/1.mp4" + ) + + XCTAssertEqual( + LivePlaybackURLResolver.playableURL(from: live)?.absoluteString, + "https://cdn.example.com/live/1.m3u8" + ) + XCTAssertEqual( + LivePlaybackURLResolver.playableURL(from: ["rtmp://a", "https://cdn.example.com/fallback.mp4"])?.absoluteString, + "https://cdn.example.com/fallback.mp4" + ) + } + + /// 测试播放器可从 LiveEntity 初始化并支持 reload。 + func testLivePlaybackViewModelLoadLiveEntityAndReload() { + let live = LiveEntity(hlsUrl: "https://cdn.example.com/live/1.m3u8") + let viewModel = LivePlaybackViewModel(live: live) + + XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8") + XCTAssertNotNil(viewModel.player) + + viewModel.play() + XCTAssertTrue(viewModel.isPlaying) + + viewModel.reload() + XCTAssertFalse(viewModel.isPlaying) + XCTAssertNotNil(viewModel.player) + XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8") + } + + /// 测试无效推流地址配置会提示格式错误。 + func testLivePushReadinessInvalidPushURL() { + let viewModel = LivePushReadinessViewModel( + permissionProvider: LivePermissionMock(camera: .granted, microphone: .granted), + networkMonitor: LiveNetworkMock(state: .wifi), + adapter: LivePushAdapterMock(available: true) + ) + + viewModel.configure(pushURL: "not-a-valid-url") + + XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.invalidPushURL.localizedDescription) + } + + /// 测试可用 SDK 时 prepare、start、stop 和 dispose 的完整流程。 + func testLivePushReadinessStartStopAndDispose() async throws { + let permissions = LivePermissionMock(camera: .granted, microphone: .granted) + let network = LiveNetworkMock(state: .wifi) + let adapter = LivePushAdapterMock(available: true) + let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter) + + viewModel.configure(pushURL: "rtmp://push.example.com/live/1") + viewModel.startMonitoring() + await viewModel.refreshPermissions() + + try viewModel.runDiagnostics() + try await viewModel.prepare() + try await viewModel.startPush() + + XCTAssertTrue(adapter.prepareCalled) + XCTAssertTrue(adapter.startCalled) + XCTAssertTrue(viewModel.running) + XCTAssertTrue(viewModel.prepared) + + await viewModel.stopPush() + XCTAssertTrue(adapter.stopCalled) + XCTAssertFalse(viewModel.running) + + await viewModel.dispose() + XCTAssertFalse(viewModel.prepared) + } + + /// 测试权限和网络状态展示文案。 + func testLivePushStateDisplayText() { + XCTAssertEqual(LivePushPermissionState.granted.displayText, "已授权") + XCTAssertEqual(LivePushNetworkState.wifi.displayText, "Wi-Fi") + XCTAssertEqual(LivePushNetworkState.unavailable.displayText, "网络不可用") + } +} + +@MainActor +private final class LiveMock: LiveServing { + var liveListResponses: [LiveListResponse] = [] + var liveDetailResponses: [LiveEntity] = [] + var albumListResponses: [LiveAlbumFolderListResponse] = [] + var albumDetailResponses: [LiveAlbumFolderItem] = [] + var liveListError: Error? + var liveDetailError: Error? + var albumListError: Error? + var albumDetailError: Error? + var createRequests: [LiveCreateRequest] = [] + var startIds: [Int] = [] + var stopIds: [Int] = [] + var finishIds: [Int] = [] + var pushModeRequests: [(liveId: Int, mode: Int)] = [] + var albumCreateRequests: [LiveAlbumCreateFolderRequest] = [] + var albumDeleteFolderIds: [Int] = [] + var albumDeleteFileRequests: [(folderId: Int, fileIds: [Int])] = [] + private(set) var liveListRequests: [(scenicId: Int, page: Int, pageSize: Int)] = [] + private(set) var albumListRequests: [(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int)] = [] + + func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse { + liveListRequests.append((scenicId, page, pageSize)) + if let liveListError { throw liveListError } + return liveListResponses.isEmpty ? LiveListResponse() : liveListResponses.removeFirst() + } + + func liveDetail(liveId: Int) async throws -> LiveEntity { + if let liveDetailError { throw liveDetailError } + return liveDetailResponses.isEmpty ? live(id: liveId) : liveDetailResponses.removeFirst() + } + + func liveCreate(_ request: LiveCreateRequest) async throws { + createRequests.append(request) + } + + func liveStart(liveId: Int) async throws { + startIds.append(liveId) + } + + func liveStop(liveId: Int) async throws { + stopIds.append(liveId) + } + + func liveFinish(liveId: Int) async throws { + finishIds.append(liveId) + } + + func liveSetPushMode(liveId: Int, mode: Int) async throws { + pushModeRequests.append((liveId, mode)) + } + + func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse { + albumListRequests.append((scenicId, startTime, endTime, page, pageSize)) + if let albumListError { throw albumListError } + return albumListResponses.isEmpty ? LiveAlbumFolderListResponse() : albumListResponses.removeFirst() + } + + func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws { + albumCreateRequests.append(request) + } + + func liveAlbumDeleteFolder(folderId: Int) async throws { + albumDeleteFolderIds.append(folderId) + } + + func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem { + if let albumDetailError { throw albumDetailError } + return albumDetailResponses.isEmpty ? folder(id: folderId) : albumDetailResponses.removeFirst() + } + + func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws { + albumDeleteFileRequests.append((folderId, fileIds)) + } +} + +@MainActor +private final class LiveUploadMock: OSSUploadServing { + var urls: [String] = [] + var error: Error? + private(set) var uploads: [(data: Data, fileName: String, fileType: Int, scenicId: Int)] = [] + + func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let error { throw error } + uploads.append((data, fileName, fileType, scenicId)) + onProgress(100) + return urls.isEmpty ? "https://cdn.example.com/\(fileName)" : urls.removeFirst() + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +private struct LivePermissionMock: LivePermissionProviding { + let camera: LivePushPermissionState + let microphone: LivePushPermissionState + + func cameraPermission() async -> LivePushPermissionState { + camera + } + + func microphonePermission() async -> LivePushPermissionState { + microphone + } +} + +private final class LiveNetworkMock: LiveNetworkMonitoring { + private(set) var currentState: LivePushNetworkState + private var onChange: (@Sendable (LivePushNetworkState) -> Void)? + + init(state: LivePushNetworkState) { + currentState = state + } + + func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) { + self.onChange = onChange + onChange(currentState) + } + + func stop() { + onChange = nil + } +} + +private final class LivePushAdapterMock: LivePushAdapter { + let name = "Mock SDK" + let isAvailable: Bool + var prepareCalled = false + var startCalled = false + var stopCalled = false + + init(available: Bool) { + isAvailable = available + } + + func prepare(pushURL: URL) async throws { + prepareCalled = true + if !isAvailable { + throw LivePushReadinessError.sdkUnavailable + } + } + + func start() async throws { + startCalled = true + if !isAvailable { + throw LivePushReadinessError.sdkUnavailable + } + } + + func stop() async throws { + stopCalled = true + } + + func dispose() async {} +} + +private func live(id: Int, title: String = "直播", status: Int = 1, manualPushMode: Int = 1) -> LiveEntity { + LiveEntity( + id: id, + title: title, + coverImg: "https://cdn.example.com/\(id).jpg", + pushUrl: "rtmp://push.example.com/live/\(id)", + duration: 3600, + status: status, + statusLabel: status == 2 ? "直播中" : (status == 3 ? "已结束" : "待开始"), + manualPushMode: manualPushMode, + viewsCount: 10 + ) +} + +private func folder(id: Int, items: [LiveAlbumFileItem] = [albumFile(id: 1)]) -> LiveAlbumFolderItem { + LiveAlbumFolderItem(id: id, albumId: id + 100, name: "相册\(id)", creator: "摄影师", items: items) +} + +private func albumFile(id: Int) -> LiveAlbumFileItem { + LiveAlbumFileItem(id: id, url: "https://cdn.example.com/\(id).jpg", type: 1, size: 100) +} + +private func date(_ value: String) -> Date { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.date(from: value)! +} + +private enum LiveTestError: LocalizedError { + case sample + var errorDescription: String? { "测试错误" } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected error", file: file, line: line) + } catch {} +} diff --git a/suixinkan_iosTests/LocationReport/LocationReportTests.swift b/suixinkan_iosTests/LocationReport/LocationReportTests.swift new file mode 100644 index 0000000..28c0579 --- /dev/null +++ b/suixinkan_iosTests/LocationReport/LocationReportTests.swift @@ -0,0 +1,179 @@ +// +// LocationReportTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 位置上报 API 测试,覆盖旧工程接口路径、参数和宽松解码。 +final class LocationReportAPITests: XCTestCase { + /// 测试上报接口携带旧工程需要的 query 参数。 + func testReportLocationUsesExpectedQuery() async throws { + let session = LocationRecordingSession(data: Self.submitResponse) + let api = LocationReportAPI(client: APIClient(session: session)) + + let response = try await api.reportLocation(staffId: 77, latitude: 30.1, longitude: 120.2, address: "入口", type: .immediate, scenicId: 88) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/report") + let query = locationQueryItems(from: request) + XCTAssertEqual(query["staff_id"], "77") + XCTAssertEqual(query["latitude"], "30.1") + XCTAssertEqual(query["longitude"], "120.2") + XCTAssertEqual(query["address"], "入口") + XCTAssertEqual(query["type"], "1") + XCTAssertEqual(query["scenic_id"], "88") + XCTAssertEqual(response.expired, 7200) + } + + /// 测试历史接口支持类型、日期和分页参数。 + func testHistoryUsesExpectedQueryAndDecodesLossyFields() async throws { + let session = LocationRecordingSession(data: Self.historyResponse) + let api = LocationReportAPI(client: APIClient(session: session)) + + let payload = try await api.locationReportList(staffId: 77, page: 0, pageSize: 0, type: .marked, startDate: "2026-06-01", endDate: "2026-06-24") + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/list") + let query = locationQueryItems(from: request) + XCTAssertEqual(query["staff_id"], "77") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(query["type"], "2") + XCTAssertEqual(query["start_date"], "2026-06-01") + XCTAssertEqual(query["end_date"], "2026-06-24") + XCTAssertEqual(payload.total, 1) + XCTAssertEqual(payload.list.first?.staffId, 77) + XCTAssertEqual(payload.list.first?.latitude, "30.1") + } + + private static let submitResponse = Data(#"{"code":100000,"msg":"ok","data":{"staff_id":77,"expired":"7200","status":"1"}}"#.utf8) + private static let historyResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"1","staff_id":"77","type":"2","latitude":30.1,"longitude":"120.2","address":"入口","ip":"127.0.0.1","remark":"ok","created_at":"2026-06-24 10:00:00"}]}}"#.utf8) +} + +@MainActor +/// 位置上报 ViewModel 测试,覆盖提交保护、类型和历史分页。 +final class LocationReportViewModelTests: XCTestCase { + /// 测试缺 staffId 或 scenicId 时禁止上报。 + func testSubmitRequiresStaffAndScenic() async { + let api = MockLocationReportService() + let viewModel = LocationReportViewModel() + viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") + + let missingStaff = await viewModel.submit(type: .immediate, staffId: nil, scenicId: 88, api: api) + let missingScenic = await viewModel.submit(type: .immediate, staffId: 77, scenicId: nil, api: api) + + XCTAssertFalse(missingStaff) + XCTAssertFalse(missingScenic) + XCTAssertEqual(api.reportRequests.count, 0) + } + + /// 测试立即上报、标记点上报和在线状态使用正确类型。 + func testSubmitUsesExpectedTypes() async { + let api = MockLocationReportService() + let viewModel = LocationReportViewModel() + viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") + viewModel.applyMarkedLocation(latitude: 31.1, longitude: 121.2, address: "标记点") + + _ = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api) + _ = await viewModel.submit(type: .marked, staffId: 77, scenicId: 88, api: api) + _ = await viewModel.setOnline(true, staffId: 77, scenicId: 88, api: api) + + XCTAssertEqual(api.reportRequests.map(\.type), [.immediate, .marked, .onlineStatus]) + XCTAssertEqual(api.reportRequests[1].latitude, 31.1) + XCTAssertEqual(viewModel.secondsUntilReport, 600) + } + + /// 测试上报失败保留当前状态并暴露错误。 + func testSubmitFailureKeepsState() async { + let api = MockLocationReportService() + api.shouldFailReport = true + let viewModel = LocationReportViewModel() + viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") + + let success = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.secondsUntilReport, 0) + XCTAssertNotNil(viewModel.errorMessage) + } + + /// 测试历史筛选和分页请求正确。 + func testHistoryFilterAndPagination() async { + let api = MockLocationReportService() + api.historyPages = [ + ListPayload(total: 2, list: [LocationReportHistoryItem.fixture(id: 1)]), + ListPayload(total: 2, list: [LocationReportHistoryItem.fixture(id: 2)]) + ] + let viewModel = LocationReportHistoryViewModel() + viewModel.selectedType = .marked + + await viewModel.reload(staffId: 77, api: api) + await viewModel.loadMore(staffId: 77, api: api) + await viewModel.loadMore(staffId: 77, api: api) + + XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) + XCTAssertEqual(api.historyRequests.map(\.page), [1, 2]) + XCTAssertEqual(api.historyRequests.first?.type, .marked) + } +} + +/// 位置上报 API 测试用 URLSession。 +private final class LocationRecordingSession: URLSessionProtocol { + let data: Data + private(set) var requests: [URLRequest] = [] + + /// 初始化测试 Session。 + init(data: Data) { + self.data = data + } + + /// 记录请求并返回成功响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response) + } +} + +@MainActor +/// 位置上报服务测试替身。 +private final class MockLocationReportService: LocationReportServing { + var shouldFailReport = false + var reportRequests: [(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int)] = [] + var historyPages: [ListPayload] = [ListPayload(total: 0, list: [])] + var historyRequests: [(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?)] = [] + + func reportLocation(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int) async throws -> LocationReportSubmitResponse { + if shouldFailReport { throw NSError(domain: "location", code: 1) } + reportRequests.append((staffId, latitude, longitude, address, type, scenicId)) + return LocationReportSubmitResponse(staffId: "\(staffId)", expired: 600, status: 1) + } + + func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload { + historyRequests.append((staffId, page, pageSize, type, startDate, endDate)) + return historyPages.isEmpty ? ListPayload(total: 0, list: []) : historyPages.removeFirst() + } +} + +private extension LocationReportHistoryItem { + /// 创建历史记录测试实体。 + static func fixture(id: Int) -> LocationReportHistoryItem { + let data = Data(#"{"id":\#(id),"staff_id":77,"type":1,"latitude":"30.1","longitude":"120.2","address":"入口","ip":"","remark":"","created_at":"2026-06-24"}"#.utf8) + return try! JSONDecoder().decode(LocationReportHistoryItem.self, from: data) + } +} + +/// 从请求中提取 query 字典。 +private func locationQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkan_iosTests/LoginViewModelTests.swift b/suixinkan_iosTests/LoginViewModelTests.swift new file mode 100644 index 0000000..a9c88ed --- /dev/null +++ b/suixinkan_iosTests/LoginViewModelTests.swift @@ -0,0 +1,408 @@ +// +// LoginViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/20. +// + +import XCTest +@testable import suixinkan_ios + +@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 testCanSubmitRequiresValidPhoneAndNonEmptyPassword() { + let viewModel = LoginViewModel() + viewModel.username = "18651857230" + viewModel.password = " secret123 " + + XCTAssertTrue(viewModel.canSubmit) + XCTAssertEqual(viewModel.trimmedPassword, "secret123") + } + + /// 测试无效手机号时不允许提交。 + func testCanSubmitIsFalseForInvalidPhone() { + let viewModel = LoginViewModel() + viewModel.username = "12345" + viewModel.password = "secret123" + + XCTAssertFalse(viewModel.canSubmit) + XCTAssertFalse(viewModel.isValidPhone) + } + + /// 测试登录校验会按顺序返回手机号、密码和协议错误。 + func testValidateForLoginReturnsExpectedErrorsInOrder() { + let viewModel = LoginViewModel() + + viewModel.username = "12345" + viewModel.password = "secret123" + viewModel.privacyChecked = true + XCTAssertEqual(viewModel.validateForLogin(), .invalidPhone) + + viewModel.username = "18651857230" + viewModel.password = " " + XCTAssertEqual(viewModel.validateForLogin(), .emptyPassword) + + viewModel.password = "secret123" + viewModel.privacyChecked = false + XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked) + + viewModel.privacyChecked = true + XCTAssertNil(viewModel.validateForLogin()) + } + + /// 测试未勾选协议时登录校验返回隐私协议错误。 + 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") + } + + /// 测试已有手机号时不会被偏好覆盖。 + func testApplyPreferencesDoesNotOverwriteExistingUsername() { + let viewModel = LoginViewModel() + viewModel.username = "13900139000" + + viewModel.applyPreferences( + LoginPreferences( + lastUsername: "18651857230", + privacyAgreementAccepted: false + ) + ) + + XCTAssertEqual(viewModel.username, "13900139000") + XCTAssertFalse(viewModel.privacyChecked) + } + + /// 测试用户同意协议后会勾选协议并关闭弹窗。 + func testAcceptAgreementChecksPrivacyAndDismissesSheet() { + let viewModel = LoginViewModel() + viewModel.showsAgreementSheet = true + + viewModel.acceptAgreement() + + XCTAssertTrue(viewModel.privacyChecked) + XCTAssertFalse(viewModel.showsAgreementSheet) + } + + /// 测试单账号登录会自动调用 set-user 并返回 completed。 + func testLoginWithSingleAccountAutoCompletesSetUser() async throws { + let session = LoginRecordingURLSession(responses: [ + try TestFixture.data(named: "v9_login_single_scenic_success"), + try TestFixture.data(named: "v9_set_scenic_user_success") + ]) + let api = AuthAPI(client: APIClient(session: session)) + let viewModel = LoginViewModel() + viewModel.username = "18651857230" + viewModel.password = "secret123" + viewModel.privacyChecked = true + + let resolution = try await viewModel.login(authAPI: api) + + guard case .completed(let response) = resolution else { + return XCTFail("单账号登录应直接完成") + } + XCTAssertEqual(response.token, "business-token-scenic") + XCTAssertFalse(viewModel.isLoading) + XCTAssertNil(viewModel.pendingAccountSelection) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/app/v9/login", + "/api/app/v9/set-user" + ]) + let loginBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[0].httpBody)) as? [String: Any]) + XCTAssertEqual(loginBody["username"] as? String, "18651857230") + let setUserBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[1].httpBody)) as? [String: Any]) + XCTAssertEqual(setUserBody["ss_user_id"] as? Int, 101) + XCTAssertNil(setUserBody["store_user_id"] as? Int) + } + + /// 测试多账号登录会保留待选账号并返回 needsAccountSelection。 + func testLoginWithMultipleAccountsNeedsSelection() async throws { + let session = LoginRecordingURLSession(responses: [ + try TestFixture.data(named: "v9_login_multi_success") + ]) + let api = AuthAPI(client: APIClient(session: session)) + let viewModel = LoginViewModel() + viewModel.username = "18651857230" + viewModel.password = "secret123" + viewModel.privacyChecked = true + + let resolution = try await viewModel.login(authAPI: api) + + guard case .needsAccountSelection(let payload) = resolution else { + return XCTFail("多账号登录应进入账号选择") + } + XCTAssertEqual(payload.tempToken, "person-temp-token") + XCTAssertEqual(payload.accounts.map(\.businessUserId), [101, 201]) + XCTAssertEqual(viewModel.pendingAccountSelection, payload) + XCTAssertEqual(session.requests.count, 1) + XCTAssertEqual(session.requests.first?.url?.path, "/api/app/v9/login") + } + + /// 测试没有可用账号时抛出 noAvailableAccount。 + func testLoginThrowsWhenNoAvailableAccounts() async throws { + let session = LoginRecordingURLSession(responses: [ + """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "person-temp-token", + "scenic_users": [], + "store_users": [] + } + } + """.data(using: .utf8)! + ]) + let api = AuthAPI(client: APIClient(session: session)) + let viewModel = LoginViewModel() + viewModel.username = "18651857230" + viewModel.password = "secret123" + viewModel.privacyChecked = true + + do { + _ = try await viewModel.login(authAPI: api) + XCTFail("没有可用账号时应抛出错误") + } catch let error as LoginFlowError { + guard case .noAvailableAccount = error else { + return XCTFail("期望 noAvailableAccount,实际为 \(error)") + } + } + XCTAssertFalse(viewModel.isLoading) + } + + /// 测试登录响应缺少 token 时抛出 missingToken。 + func testLoginThrowsWhenTokenMissing() async throws { + let session = LoginRecordingURLSession(responses: [ + """ + { + "code": 100000, + "msg": "success", + "data": { + "token": "", + "scenic_users": [ + { + "account_type": "scenic_user", + "id": 101, + "user_id": 101, + "scenic_user_id": 101, + "ss_user_id": 101, + "username": "scenic_admin", + "real_name": "张三", + "nickname": "张三", + "phone": "13800138000", + "scenic_id": 10, + "scenic_name": "示例景区", + "is_current": false + } + ], + "store_users": [] + } + } + """.data(using: .utf8)! + ]) + let api = AuthAPI(client: APIClient(session: session)) + let viewModel = LoginViewModel() + viewModel.username = "18651857230" + viewModel.password = "secret123" + viewModel.privacyChecked = true + + do { + _ = try await viewModel.login(authAPI: api) + XCTFail("缺少 token 时应抛出错误") + } catch let error as LoginFlowError { + guard case .missingToken = error else { + return XCTFail("期望 missingToken,实际为 \(error)") + } + } + } + + /// 测试重复发起登录时会抛出 CancellationError。 + func testLoginWhileLoadingThrowsCancellationError() async throws { + let session = LoginRecordingURLSession(responses: [ + try TestFixture.data(named: "v9_login_single_scenic_success"), + try TestFixture.data(named: "v9_set_scenic_user_success") + ]) + let api = AuthAPI(client: APIClient(session: session)) + let viewModel = LoginViewModel() + viewModel.username = "18651857230" + viewModel.password = "secret123" + viewModel.privacyChecked = true + viewModel.isLoading = true + + do { + _ = try await viewModel.login(authAPI: api) + XCTFail("加载中重复登录应被取消") + } catch { + XCTAssertTrue(error is CancellationError) + } + } + + /// 测试选择账号后会用临时 token 调用 set-user 并清空待选状态。 + func testSelectAccountCompletesWithFinalToken() async throws { + let session = LoginRecordingURLSession(responses: [ + try TestFixture.data(named: "v9_set_store_user_success") + ]) + let api = AuthAPI(client: APIClient(session: session)) + let account = AccountSwitchAccount( + accountType: V9StoreUser.accountTypeValue, + businessUserId: 201, + title: "示例门店", + subtitle: "示例景区", + phone: "13800138000", + avatar: "", + scenicName: "示例景区", + storeId: 20, + storeName: "示例门店", + scenicId: 10, + isCurrent: false + ) + let viewModel = LoginViewModel() + viewModel.pendingAccountSelection = AccountSelectionPayload( + tempToken: "person-temp-token", + accounts: [account] + ) + + let response = try await viewModel.selectAccount(account, authAPI: api) + + XCTAssertEqual(response.token, "business-token-store") + XCTAssertNil(viewModel.pendingAccountSelection) + XCTAssertFalse(viewModel.isSelectingAccount) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/app/v9/set-user") + XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "person-temp-token") + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]) + XCTAssertEqual(json["store_user_id"] as? Int, 201) + } + + /// 测试缺少待选账号时 selectAccount 会抛出 missingToken。 + func testSelectAccountThrowsWhenPendingSelectionMissing() async { + let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: []))) + let account = AccountSwitchAccount( + accountType: V9StoreUser.accountTypeValue, + businessUserId: 201, + title: "示例门店", + subtitle: "示例景区", + phone: "13800138000", + avatar: "", + scenicName: "示例景区", + storeId: 20, + storeName: "示例门店", + scenicId: 10, + isCurrent: false + ) + let viewModel = LoginViewModel() + + do { + _ = try await viewModel.selectAccount(account, authAPI: api) + XCTFail("缺少待选账号时应抛出错误") + } catch let error as LoginFlowError { + guard case .missingToken = error else { + return XCTFail("期望 missingToken,实际为 \(error)") + } + } catch { + XCTFail("意外错误: \(error)") + } + } + + /// 测试无效业务账号 ID 时 selectAccount 会抛出 invalidAccount。 + func testSelectAccountThrowsWhenAccountInvalid() async { + let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: []))) + let account = AccountSwitchAccount( + accountType: V9StoreUser.accountTypeValue, + businessUserId: 0, + title: "无效账号", + subtitle: "", + phone: "", + avatar: "", + scenicName: "", + storeId: nil, + storeName: "", + scenicId: nil, + isCurrent: false + ) + let viewModel = LoginViewModel() + viewModel.pendingAccountSelection = AccountSelectionPayload( + tempToken: "person-temp-token", + accounts: [account] + ) + + do { + _ = try await viewModel.selectAccount(account, authAPI: api) + XCTFail("无效账号 ID 时应抛出错误") + } catch let error as LoginFlowError { + guard case .invalidAccount = error else { + return XCTFail("期望 invalidAccount,实际为 \(error)") + } + } catch { + XCTFail("意外错误: \(error)") + } + } + + /// 测试取消账号选择时会清空待选状态。 + func testClearPendingAccountSelection() { + let viewModel = LoginViewModel() + viewModel.pendingAccountSelection = AccountSelectionPayload( + tempToken: "person-temp-token", + accounts: [] + ) + + viewModel.clearPendingAccountSelection() + + XCTAssertNil(viewModel.pendingAccountSelection) + } +} + +/// 登录测试用 URLSession 替身,按顺序返回预设响应。 +private final class LoginRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化顺序响应。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回下一份响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} diff --git a/suixinkan_iosTests/MainTabBadgeViewModelTests.swift b/suixinkan_iosTests/MainTabBadgeViewModelTests.swift new file mode 100644 index 0000000..565b7c2 --- /dev/null +++ b/suixinkan_iosTests/MainTabBadgeViewModelTests.swift @@ -0,0 +1,143 @@ +// +// MainTabBadgeViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 主 Tab 角标测试,覆盖待核销数量加载、封顶和失败清理。 +final class MainTabBadgeViewModelTests: XCTestCase { + /// 测试无景区时清空角标且不请求接口。 + func testMissingScenicClearsBadgeWithoutRequestingAPI() async { + let api = MockMainTabOrderService() + let viewModel = MainTabBadgeViewModel() + + await viewModel.refreshPendingWriteOffCount(api: api, scenicId: nil, storeId: 66) + + XCTAssertNil(viewModel.pendingWriteOffCount) + XCTAssertTrue(api.writeOffListCalls.isEmpty) + } + + /// 测试接口成功时展示待核销总数。 + func testSuccessfulRefreshStoresPendingWriteOffCount() async { + let api = MockMainTabOrderService() + api.writeOffResponse = ListPayload(total: 8, list: []) + let viewModel = MainTabBadgeViewModel() + + await viewModel.refreshPendingWriteOffCount(api: api, scenicId: 88, storeId: 66) + + XCTAssertEqual(viewModel.pendingWriteOffCount, 8) + XCTAssertEqual(api.writeOffListCalls, [.init(scenicId: 88, storeId: 66, page: 1, pageSize: 1)]) + } + + /// 测试待核销数量超过 99 时角标封顶。 + func testSuccessfulRefreshCapsPendingWriteOffCountAtNinetyNine() async { + let api = MockMainTabOrderService() + api.writeOffResponse = ListPayload(total: 120, list: []) + let viewModel = MainTabBadgeViewModel() + + await viewModel.refreshPendingWriteOffCount(api: api, scenicId: 88, storeId: nil) + + XCTAssertEqual(viewModel.pendingWriteOffCount, 99) + } + + /// 测试接口失败时清空角标。 + func testRefreshFailureClearsBadge() async { + let api = MockMainTabOrderService() + api.writeOffError = APIError.httpStatus(500, "server error") + let viewModel = MainTabBadgeViewModel() + + await viewModel.refreshPendingWriteOffCount(api: api, scenicId: 88, storeId: 66) + + XCTAssertNil(viewModel.pendingWriteOffCount) + XCTAssertEqual(api.writeOffListCalls.count, 1) + } +} + +@MainActor +/// 主 Tab 角标测试用订单服务,只实现角标依赖的核销列表记录。 +private final class MockMainTabOrderService: OrderServing { + struct WriteOffListCall: Equatable { + let scenicId: Int + let storeId: Int? + let page: Int + let pageSize: Int + } + + var writeOffResponse = ListPayload(total: 0, list: []) + var writeOffError: Error? + private(set) var writeOffListCalls: [WriteOffListCall] = [] + + /// 返回空订单管理列表,角标测试不会使用该方法。 + func orderList( + scenicId: Int, + page: Int, + pageSize: Int, + orderStatus: Int?, + userPhone: String?, + startTime: String?, + endTime: String?, + isRefined: Int?, + isScenicAdmin: Bool + ) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 记录核销列表请求并返回预设响应。 + func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { + writeOffListCalls.append(.init(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize)) + if let writeOffError { + throw writeOffError + } + return writeOffResponse + } + + /// 角标测试不会触发核销操作。 + func writeOff(orderNumber: String) async throws {} + + /// 角标测试不会请求订单详情。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { + try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") + } + + /// 角标测试不会请求押金订单列表。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 角标测试不会触发押金核销。 + func depositOrderWriteOff(orderNumber: String) async throws {} + + /// 角标测试不会触发押金退款。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws {} + + /// 角标测试不会请求拍摄信息。 + func storeOrderShootingDetail( + storeId: Int, + orderNumber: String, + scenicSpotId: Int, + photogUid: Int + ) async throws -> StoreOrderShootingDetailResponse { + try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") + } + + /// 角标测试不会触发普通退款。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {} + + /// 角标测试不会请求历史拍摄。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") + } + + /// 角标测试不会请求已核销打卡点。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + [] + } + + /// 角标测试不会提交多点旅拍素材。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {} +} diff --git a/suixinkan_iosTests/MessageCenter/MessageCenterViewModelTests.swift b/suixinkan_iosTests/MessageCenter/MessageCenterViewModelTests.swift new file mode 100644 index 0000000..53aa0f1 --- /dev/null +++ b/suixinkan_iosTests/MessageCenter/MessageCenterViewModelTests.swift @@ -0,0 +1,167 @@ +// +// MessageCenterViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 消息中心测试,覆盖分页、筛选、已读和删除。 +final class MessageCenterViewModelTests: XCTestCase { + func testReloadFirstPageMapsMessagesAndPaginationState() async { + let api = MessageCenterMock() + api.listResponses = [ + MessageListResponse(hasMore: true, lastId: 101, items: [ + MessageEntity(id: 101, type: 1, typeName: "", title: "新订单提醒", content: "有新订单", pushAt: "2026-06-25 10:00", isRead: false), + MessageEntity(id: 100, type: 2, typeName: "核销", title: "", content: "核销完成", pushAt: "2026-06-25 09:00", isRead: true) + ]) + ] + let viewModel = MessageCenterViewModel() + viewModel.selectedFilter = .unread + + await viewModel.reloadFirstPage(api: api) + + XCTAssertEqual(api.listRequests.map(\.unread), [1]) + XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100"]) + XCTAssertEqual(viewModel.messages[0].title, "新订单提醒") + XCTAssertEqual(viewModel.messages[0].type, .order) + XCTAssertEqual(viewModel.messages[1].title, "核销") + XCTAssertEqual(viewModel.messages[1].type, .writeOff) + XCTAssertEqual(viewModel.unreadCount, 1) + XCTAssertTrue(viewModel.hasMoreMessages) + XCTAssertEqual(viewModel.lastId, 101) + } + + func testLoadMoreDeduplicatesSortsAndStopsAtTail() async { + let api = MessageCenterMock() + api.listResponses = [ + MessageListResponse(hasMore: true, lastId: 101, items: [ + MessageEntity(id: 101, type: 1, title: "A", content: "A", pushAt: "2026-06-25 10:00"), + MessageEntity(id: 100, type: 3, title: "B", content: "B", pushAt: "2026-06-25 09:00") + ]), + MessageListResponse(hasMore: false, lastId: 99, items: [ + MessageEntity(id: 100, type: 3, title: "B2", content: "B2", pushAt: "2026-06-25 09:00"), + MessageEntity(id: 99, type: 0, title: "C", content: "C", pushAt: "2026-06-24 08:00") + ]) + ] + let viewModel = MessageCenterViewModel() + + await viewModel.reloadFirstPage(api: api) + await viewModel.loadMore(api: api) + await viewModel.loadMore(api: api) + + XCTAssertEqual(api.listRequests.map(\.lastId), [0, 101]) + XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100", "msg_99"]) + XCTAssertEqual(viewModel.messages[1].title, "B2") + XCTAssertFalse(viewModel.hasMoreMessages) + XCTAssertEqual(viewModel.lastId, 99) + } + + func testReloadFailureClearsStaleMessagesAndPaging() async { + let api = MessageCenterMock() + api.listResponses = [ + MessageListResponse(hasMore: true, lastId: 101, items: [ + MessageEntity(id: 101, type: 1, title: "A") + ]) + ] + let viewModel = MessageCenterViewModel() + await viewModel.reloadFirstPage(api: api) + + api.listError = TestError.sample + await viewModel.reloadFirstPage(api: api) + + XCTAssertTrue(viewModel.messages.isEmpty) + XCTAssertFalse(viewModel.hasMoreMessages) + XCTAssertEqual(viewModel.lastId, 0) + XCTAssertTrue(viewModel.loadFailed) + } + + func testMarkReadAllReadAndDeleteUpdateOnlyAfterSuccess() async throws { + let api = MessageCenterMock() + api.listResponses = [ + MessageListResponse(items: [ + MessageEntity(id: 101, type: 1, title: "A", isRead: false), + MessageEntity(id: 100, type: 0, title: "B", isRead: false) + ]) + ] + let viewModel = MessageCenterViewModel() + await viewModel.reloadFirstPage(api: api) + + try await viewModel.markAsRead(api: api, item: try XCTUnwrap(viewModel.messages.first)) + XCTAssertEqual(api.readIds, [101]) + XCTAssertEqual(viewModel.messages.map(\.isRead), [true, false]) + + api.readIds = [] + try await viewModel.markAllAsRead(api: api) + XCTAssertEqual(api.readIds, [100]) + XCTAssertTrue(viewModel.messages.allSatisfy(\.isRead)) + + let item = try XCTUnwrap(viewModel.messages.first) + try await viewModel.deleteMessage(api: api, item: item) + XCTAssertEqual(api.deleteIds, [101]) + XCTAssertFalse(viewModel.messages.contains(where: { $0.id == item.id })) + } + + func testFailedReadAndDeleteKeepLocalState() async throws { + let api = MessageCenterMock() + api.listResponses = [MessageListResponse(items: [MessageEntity(id: 101, type: 1, title: "A", isRead: false)])] + let viewModel = MessageCenterViewModel() + await viewModel.reloadFirstPage(api: api) + let item = try XCTUnwrap(viewModel.messages.first) + + api.readError = TestError.sample + await XCTAssertThrowsErrorAsync(try await viewModel.markAsRead(api: api, item: item)) + XCTAssertFalse(try XCTUnwrap(viewModel.messages.first).isRead) + + api.readError = nil + api.deleteError = TestError.sample + await XCTAssertThrowsErrorAsync(try await viewModel.deleteMessage(api: api, item: item)) + XCTAssertEqual(viewModel.messages.map(\.id), [item.id]) + } +} + +@MainActor +private final class MessageCenterMock: MessageCenterServing { + var listResponses: [MessageListResponse] = [] + var listError: Error? + var readError: Error? + var deleteError: Error? + var readIds: [Int] = [] + var deleteIds: [Int] = [] + private(set) var listRequests: [(lastId: Int, limit: Int, unread: Int)] = [] + + func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse { + listRequests.append((lastId, limit, unread)) + if let listError { throw listError } + return listResponses.isEmpty ? MessageListResponse() : listResponses.removeFirst() + } + + func messageRead(id: Int) async throws { + if let readError { throw readError } + readIds.append(id) + } + + func messageDelete(id: Int) async throws { + if let deleteError { throw deleteError } + deleteIds.append(id) + } +} + +private enum TestError: LocalizedError { + case sample + var errorDescription: String? { "测试错误" } +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + try await expression() + XCTFail("Expected error", file: file, line: line) + } catch {} +} diff --git a/suixinkan_iosTests/NavigationRouterTests.swift b/suixinkan_iosTests/NavigationRouterTests.swift new file mode 100644 index 0000000..344664a --- /dev/null +++ b/suixinkan_iosTests/NavigationRouterTests.swift @@ -0,0 +1,96 @@ +// +// NavigationRouterTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +/// 主导航路由测试,覆盖 AppRoute 的跨页面导航策略。 +final class NavigationRouterTests: XCTestCase { + /// 测试通过 NavigationStack push 的子页面默认隐藏底部 TabBar。 + func testPushedRoutesHideTabBarByDefault() { + XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.orders(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed) + XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).hidesTabBarWhenPushed) + } + + /// 测试切换订单 Tab 时会保存订单内部子入口。 + func testSelectOrdersUpdatesTabAndEntry() { + let router = AppRouter() + + router.selectOrders(entry: .verificationOrders) + + XCTAssertEqual(router.selectedTab, .orders) + XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders) + } + + /// 测试全局扫码核销路由会切到订单核销入口并保存扫码结果。 + func testRouteToOrderVerificationStoresPendingScanCode() { + let router = AppRouter() + + router.routeToOrderVerification(scannedCode: "order_number=VERIFY001") + + XCTAssertEqual(router.selectedTab, .orders) + XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders) + XCTAssertEqual(router.pendingOrderScanCode, "order_number=VERIFY001") + } + + /// 测试全局扫码结果只能被消费一次。 + func testConsumePendingOrderScanCodeClearsValueAfterFirstRead() { + let router = AppRouter() + router.routeToOrderVerification(scannedCode: "VERIFY001") + + XCTAssertEqual(router.consumePendingOrderScanCode(), "VERIFY001") + XCTAssertNil(router.consumePendingOrderScanCode()) + } + + /// 测试重置主路由时会清理未消费的扫码结果。 + func testResetClearsPendingOrderScanCode() { + let router = AppRouter() + router.routeToOrderVerification(scannedCode: "VERIFY001") + + router.reset() + + XCTAssertEqual(router.selectedTab, .home) + XCTAssertEqual(router.selectedOrdersEntry, .storeOrders) + XCTAssertNil(router.pendingOrderScanCode) + } + + /// 测试订单模块真实路由可被压入导航栈。 + func testOrderRoutesCanBePushedIntoRouterPath() throws { + let router = RouterPath() + let order = try Self.firstOrder() + let writeOff = try Self.firstWriteOffOrder() + + router.navigate(to: .orders(.storeDetail(order))) + router.navigate(to: .orders(.writeOffDetail(writeOff))) + router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: "MT001"))) + router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告"))) + + XCTAssertEqual(router.path.count, 4) + XCTAssertEqual(router.path[0], .orders(.storeDetail(order))) + XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff))) + XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001"))) + XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告"))) + } + + /// 返回测试用订单管理列表项。 + private static func firstOrder() throws -> OrderEntity { + let payload = try TestFixture.payload(ListPayload.self, named: "order_v2_success") + return try XCTUnwrap(payload.list.first) + } + + /// 返回测试用核销订单列表项。 + private static func firstWriteOffOrder() throws -> WriteOffOrderItem { + let payload = try TestFixture.payload(ListPayload.self, named: "writeoff_list_success") + return try XCTUnwrap(payload.list.first) + } +} diff --git a/suixinkan_iosTests/OperatingArea/OperatingAreaTests.swift b/suixinkan_iosTests/OperatingArea/OperatingAreaTests.swift new file mode 100644 index 0000000..365fac8 --- /dev/null +++ b/suixinkan_iosTests/OperatingArea/OperatingAreaTests.swift @@ -0,0 +1,171 @@ +// +// OperatingAreaTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 运营区域测试,覆盖接口编码、角色分流和围栏解析。 +final class OperatingAreaTests: XCTestCase { + func testAPIsUseExpectedQueriesAndDecodeFixture() async throws { + let session = OperatingRecordingURLSession(data: try TestFixture.data(named: "operating_area_success")) + let api = OperatingAreaAPI(client: APIClient(session: session)) + + let storeResponse = try await api.storeBusinessArea(storeId: 101) + XCTAssertEqual(storeResponse.total, 4) + XCTAssertEqual(storeResponse.list.first?.id, 101) + var request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/store/business-area") + XCTAssertEqual(queryItems(from: request)["store_id"], "101") + + _ = try await api.scenicAdminBusinessArea(scenicId: 88) + request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/business-area") + XCTAssertEqual(queryItems(from: request)["scenic_id"], "88") + } + + func testViewModelUsesStoreAndScenicModes() async { + let api = OperatingMock() + api.response = ListPayload(total: 1, list: [area(id: 101)]) + + let storeViewModel = OperatingAreaViewModel() + await storeViewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertEqual(api.storeIds, [101]) + XCTAssertEqual(storeViewModel.fenceRings.first?.isCurrentStore, true) + + let scenicViewModel = OperatingAreaViewModel() + await scenicViewModel.reload(api: api, accountContext: accountContext(scenicId: 88, storeId: nil), permissionContext: permissionContext(roleName: "景区管理员")) + XCTAssertEqual(api.scenicIds, [88]) + XCTAssertEqual(scenicViewModel.mode, .scenicAdmin(scenicId: 88)) + } + + func testViewModelBlocksMissingContextAndFailureClearsData() async { + let api = OperatingMock() + let viewModel = OperatingAreaViewModel() + + await viewModel.reload(api: api, accountContext: accountContext(scenicId: nil, storeId: nil), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertEqual(viewModel.blockReason, .missingStore) + XCTAssertTrue(api.storeIds.isEmpty) + + api.response = ListPayload(total: 1, list: [area(id: 101)]) + await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertFalse(viewModel.fenceRings.isEmpty) + + api.error = OperatingTestError.sample + await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertTrue(viewModel.fenceRings.isEmpty) + XCTAssertEqual(viewModel.blockReason, .backendMessage("测试错误")) + } + + func testParserSupportsGeoJSONStringArraysAndFiltersInvalidRings() throws { + let response = try TestFixture.payload(ListPayload.self, named: "operating_area_success") + let rings = response.list.flatMap { OperatingAreaParser.parseToRings($0.businessMapArea) } + + XCTAssertEqual(rings.count, 3) + XCTAssertEqual(rings.first?.first, OperatingGeoPoint(latitude: 32.041, longitude: 118.781)) + XCTAssertEqual(rings[1].first, OperatingGeoPoint(latitude: 32.051, longitude: 118.791)) + XCTAssertEqual(rings[2].first, OperatingGeoPoint(latitude: 32.061, longitude: 118.801)) + } + + func testEmptyAndUnparsableAreaStates() async { + let api = OperatingMock() + let viewModel = OperatingAreaViewModel() + + api.response = ListPayload(total: 0, list: []) + await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertEqual(viewModel.blockReason, .emptyAreaList) + + api.response = ListPayload(total: 1, list: [OperatingAreaItem(id: 1, name: "bad", businessMapArea: .array([.array([.number(1), .number(2)])]))]) + await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员")) + XCTAssertEqual(viewModel.blockReason, .noParsableFenceData) + } +} + +@MainActor +private final class OperatingMock: OperatingAreaServing { + var response = ListPayload(total: 0, list: []) + var error: Error? + var storeIds: [Int] = [] + var scenicIds: [Int] = [] + + func storeBusinessArea(storeId: Int) async throws -> ListPayload { + storeIds.append(storeId) + if let error { throw error } + return response + } + + func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload { + scenicIds.append(scenicId) + if let error { throw error } + return response + } +} + +private final class OperatingRecordingURLSession: URLSessionProtocol { + private let data: Data + private(set) var requests: [URLRequest] = [] + + init(data: Data) { + self.data = data + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +private enum OperatingTestError: LocalizedError { + case sample + + var errorDescription: String? { "测试错误" } +} + +private func area(id: Int) -> OperatingAreaItem { + OperatingAreaItem( + id: id, + name: "区域\(id)", + businessMapArea: .array([ + .array([ + .array([.number(118.781), .number(32.041)]), + .array([.number(118.782), .number(32.041)]), + .array([.number(118.782), .number(32.042)]) + ]) + ]), + statusText: "启用", + typeText: "门店", + auditStatusText: "通过" + ) +} + +@MainActor +private func accountContext(scenicId: Int? = 88, storeId: Int? = 101) -> AccountContext { + let context = AccountContext() + let scenics = scenicId.map { [BusinessScope(id: $0, name: "测试景区", kind: .scenic)] } ?? [] + let stores = storeId.map { [BusinessScope(id: $0, name: "测试门店", kind: .store, parentScenicId: scenicId)] } ?? [] + context.replaceScopes(scenic: scenics, stores: stores, currentScenicId: scenicId, currentStoreId: storeId) + return context +} + +@MainActor +private func permissionContext(roleName: String) -> PermissionContext { + let context = PermissionContext() + let role = RoleInfo(id: 1, name: roleName, permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")]) + context.replaceRolePermissions([RolePermissionResponse(role: role, scenic: [])], currentRoleId: 1) + return context +} + +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return [:] } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkan_iosTests/OrderDetailViewModelTests.swift b/suixinkan_iosTests/OrderDetailViewModelTests.swift new file mode 100644 index 0000000..a66a10c --- /dev/null +++ b/suixinkan_iosTests/OrderDetailViewModelTests.swift @@ -0,0 +1,169 @@ +// +// OrderDetailViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 订单详情 ViewModel 测试,覆盖详情加载、缺失上下文和错误兜底。 +final class OrderDetailViewModelTests: XCTestCase { + /// 测试有门店 ID 时请求详情接口并展示服务端字段。 + func testLoadRequestsDetailWhenStoreIdExists() async throws { + let item = try firstOrder() + let api = DetailMockOrderService() + api.detailResponses = [try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")] + let viewModel = OrderDetailViewModel(item: item) + + await viewModel.load(api: api, fallbackStoreId: 66) + + XCTAssertEqual(api.detailCalls.count, 1) + XCTAssertEqual(api.detailCalls[0].storeId, 66) + XCTAssertEqual(api.detailCalls[0].orderNumber, "ORDV2001") + XCTAssertEqual(viewModel.display.projectName, "亲子跟拍") + XCTAssertEqual(viewModel.projectInfo?.settleSpotNum, 3) + XCTAssertEqual(viewModel.shootingList.first?.scenicSpotName, "东门") + XCTAssertNil(viewModel.errorMessage) + } + + /// 测试缺少门店 ID 时不请求接口并保留列表摘要。 + func testLoadWithoutStoreIdUsesSummaryOnly() async throws { + let item = try firstOrder() + let api = DetailMockOrderService() + let viewModel = OrderDetailViewModel(item: item) + + await viewModel.load(api: api, fallbackStoreId: nil) + + XCTAssertTrue(api.detailCalls.isEmpty) + XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001") + XCTAssertEqual(viewModel.contextMessage, "缺少门店上下文,当前展示订单摘要。") + } + + /// 测试详情接口失败时保留摘要并暴露错误信息。 + func testLoadFailureKeepsSummaryAndExposesError() async throws { + let item = try firstOrder() + let api = DetailMockOrderService() + api.detailError = APIError.httpStatus(500, "server error") + let viewModel = OrderDetailViewModel(item: item) + + await viewModel.load(api: api, fallbackStoreId: 66) + + XCTAssertEqual(api.detailCalls.count, 1) + XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001") + XCTAssertEqual(viewModel.contextMessage, "详情加载失败,当前展示订单摘要。") + XCTAssertNotNil(viewModel.errorMessage) + } + + /// 测试详情模型兼容后端数字和字符串混合字段。 + func testDetailResponseDecodesMixedNumericAndStringFields() throws { + let detail = try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") + + XCTAssertEqual(detail.orderType, 19) + XCTAssertEqual(detail.actualRefundAmount, "0") + XCTAssertEqual(detail.userId, 7001) + XCTAssertEqual(detail.multiTravel?.projectInfo?.singleSpotVideoNum, 1) + XCTAssertEqual(detail.multiTravel?.shootingList.first?.startAvg, 4.8) + } + + /// 返回订单列表 fixture 中的第一条订单。 + private func firstOrder() throws -> OrderEntity { + let payload = try TestFixture.payload(ListPayload.self, named: "order_v2_success") + return try XCTUnwrap(payload.list.first) + } +} + +@MainActor +/// 订单详情服务测试替身,专门用于详情 ViewModel 测试。 +private final class DetailMockOrderService: OrderServing { + var detailResponses: [StoreOrderDetailResponse] = [] + var detailError: Error? + private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = [] + + /// 订单列表接口在详情测试中不会被调用。 + func orderList( + scenicId: Int, + page: Int, + pageSize: Int, + orderStatus: Int?, + userPhone: String?, + startTime: String?, + endTime: String?, + isRefined: Int?, + isScenicAdmin: Bool + ) async throws -> ListPayload { + XCTFail("orderList should not be called") + return ListPayload(total: 0, list: []) + } + + /// 核销列表接口在详情测试中不会被调用。 + func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { + XCTFail("writeOffList should not be called") + return ListPayload(total: 0, list: []) + } + + /// 核销接口在详情测试中不会被调用。 + func writeOff(orderNumber: String) async throws { + XCTFail("writeOff should not be called") + } + + /// 返回预置的订单详情响应。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { + detailCalls.append((storeId: storeId, orderNumber: orderNumber)) + if let detailError { + throw detailError + } + return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() + } + + /// 押金订单列表接口在详情测试中不会被调用。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + XCTFail("depositOrderList should not be called") + return ListPayload(total: 0, list: []) + } + + /// 押金核销接口在详情测试中不会被调用。 + func depositOrderWriteOff(orderNumber: String) async throws { + XCTFail("depositOrderWriteOff should not be called") + } + + /// 押金退款接口在详情测试中不会被调用。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws { + XCTFail("depositOrderRefund should not be called") + } + + /// 拍摄信息接口在详情测试中不会被调用。 + func storeOrderShootingDetail( + storeId: Int, + orderNumber: String, + scenicSpotId: Int, + photogUid: Int + ) async throws -> StoreOrderShootingDetailResponse { + XCTFail("storeOrderShootingDetail should not be called") + return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") + } + + /// 普通退款接口在详情测试中不会被调用。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { + XCTFail("orderRefund should not be called") + } + + /// 历史拍摄接口在详情测试中不会被调用。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + XCTFail("multiTravelShootHistory should not be called") + return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") + } + + /// 已核销打卡点接口在详情测试中不会被调用。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + XCTFail("multiTravelVerifiedScenicSpotList should not be called") + return [] + } + + /// 多点旅拍素材提交接口在详情测试中不会被调用。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { + XCTFail("multiTravelUploadMaterial should not be called") + } +} diff --git a/suixinkan_iosTests/OrderLongTailTests.swift b/suixinkan_iosTests/OrderLongTailTests.swift new file mode 100644 index 0000000..2bbaeba --- /dev/null +++ b/suixinkan_iosTests/OrderLongTailTests.swift @@ -0,0 +1,596 @@ +// +// OrderLongTailTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 订单长尾测试,覆盖押金订单、退款和历史拍摄的 API 与 ViewModel 规则。 +final class OrderLongTailTests: XCTestCase { + /// 测试押金订单接口使用旧工程路径、分页兜底和请求体。 + func testDepositOrderAPIRequestsAndDecodesLossyFields() async throws { + let session = LongTailRecordingURLSession(responses: [ + try TestFixture.data(named: "deposit_order_list_success"), + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = OrdersAPI(client: APIClient(session: session)) + + let list = try await api.depositOrderList(scenicId: 88, page: 0, pageSize: 0) + try await api.depositOrderWriteOff(orderNumber: "DEP001") + try await api.depositOrderRefund(orderNumber: "DEP002", refundReason: "游客取消行程") + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/order/deposit-list", + "/api/yf-handset-app/photog/order/deposit-writeoff", + "/api/yf-handset-app/photog/order/deposit-refund" + ]) + let query = longTailQueryItems(from: try XCTUnwrap(session.requests.first)) + XCTAssertEqual(query["scenic_id"], "88") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(list.total, 2) + XCTAssertEqual(list.list[1].id, 4102) + XCTAssertEqual(list.list[1].userPhone, "13900000000") + + let writeOffBody = try longTailBodyObject(from: session.requests[1]) + XCTAssertEqual(writeOffBody["order_number"] as? String, "DEP001") + let refundBody = try longTailBodyObject(from: session.requests[2]) + XCTAssertEqual(refundBody["order_number"] as? String, "DEP002") + XCTAssertEqual(refundBody["refund_reason"] as? String, "游客取消行程") + } + + /// 测试拍摄信息、普通退款和历史拍摄接口 path、query、body 与宽松解码。 + func testShootingRefundAndHistoryAPIRequestsAndDecodes() async throws { + let session = LongTailRecordingURLSession(responses: [ + try TestFixture.data(named: "store_order_shooting_detail_success"), + try TestFixture.data(named: "multi_travel_shoot_history_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = OrdersAPI(client: APIClient(session: session)) + + let shooting = try await api.storeOrderShootingDetail( + storeId: 66, + orderNumber: "DEP001", + scenicSpotId: 901, + photogUid: 42 + ) + let history = try await api.multiTravelShootHistory(orderNumber: "MT001") + try await api.orderRefund( + orderNumber: "STORE001", + refundType: .partial, + refundAmount: "10.50", + refundReason: "游客取消部分项目" + ) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/app/store/order/shooting-detail", + "/api/yf-handset-app/photog/order/multi-travel/shoot-history", + "/api/yf-handset-app/photog/order/refund" + ]) + let shootingQuery = longTailQueryItems(from: session.requests[0]) + XCTAssertEqual(shootingQuery["store_id"], "66") + XCTAssertEqual(shootingQuery["order_number"], "DEP001") + XCTAssertEqual(shootingQuery["scenic_spot_id"], "901") + XCTAssertEqual(shootingQuery["photog_uid"], "42") + XCTAssertEqual(shooting.orderComment?.starShooting, 5) + XCTAssertEqual(shooting.materialList.first?.fileSize, 10_485_760) + XCTAssertTrue(shooting.materialList.first?.isVideo == true) + + XCTAssertEqual(longTailQueryItems(from: session.requests[1])["order_number"], "MT001") + XCTAssertEqual(history.projectType, 19) + XCTAssertEqual(history.photogSpotList.first?.photographerDisplayName, "小景") + XCTAssertEqual(history.photogSpotList.first?.files.count, 2) + + let refundBody = try longTailBodyObject(from: session.requests[2]) + XCTAssertEqual(refundBody["order_number"] as? String, "STORE001") + XCTAssertEqual(refundBody["refund_type"] as? Int, OrderRefundMode.partial.rawValue) + XCTAssertEqual(refundBody["refund_amount"] as? String, "10.50") + XCTAssertNil(refundBody["store_id"]) + } + + /// 测试已核销打卡点和多点旅拍素材提交接口 path、query、body。 + func testMultiTravelTaskUploadAPIRequestsAndDecodes() async throws { + let session = LongTailRecordingURLSession(responses: [ + try TestFixture.data(named: "multi_travel_verified_spots_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = OrdersAPI(client: APIClient(session: session)) + + let spots = try await api.multiTravelVerifiedScenicSpotList(orderNumber: "MT001") + try await api.multiTravelUploadMaterial( + MultiTravelUploadMaterialRequest( + orderNumber: "MT001", + scenicSpotId: 901, + cloudFile: [MultiTravelCloudFileItem(fileId: 7001)], + uploadFile: [MultiTravelUploadFileItem(fileName: "spot.jpg", fileUrl: "https://cdn.example.com/spot.jpg")] + ) + ) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list", + "/api/yf-handset-app/photog/order/multi-travel/upload-material" + ]) + XCTAssertEqual(longTailQueryItems(from: session.requests[0])["order_number"], "MT001") + XCTAssertEqual(spots.map(\.id), [901, 902]) + XCTAssertEqual(spots.last?.name, "观景台") + let body = try longTailBodyObject(from: session.requests[1]) + XCTAssertEqual(body["order_number"] as? String, "MT001") + XCTAssertEqual(body["scenic_spot_id"] as? Int, 901) + XCTAssertEqual((body["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 7001) + XCTAssertEqual((body["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/spot.jpg") + } + + /// 测试押金订单无景区时清空旧数据且不请求接口。 + func testDepositListClearsWithoutScenicAndStopsAtLastPage() async throws { + let service = LongTailMockOrderService() + service.depositListResponses = [try Self.depositPayload()] + let viewModel = DepositOrderListViewModel() + + await viewModel.reload(api: service, scenicId: 88, reset: true) + await viewModel.reload(api: service, scenicId: 88, reset: false) + await viewModel.reload(api: service, scenicId: nil, reset: true) + + XCTAssertTrue(viewModel.orders.isEmpty) + XCTAssertEqual(viewModel.total, 0) + XCTAssertEqual(viewModel.page, 1) + XCTAssertEqual(service.depositListCalls, [ + .init(scenicId: 88, page: 1, pageSize: 10) + ]) + } + + /// 测试押金核销和押金退款成功后刷新列表,失败时保留旧列表并清理操作状态。 + func testDepositWriteOffAndRefundRefreshOrKeepListOnFailure() async throws { + let service = LongTailMockOrderService() + service.depositListResponses = [ + try Self.depositPayload(), + try Self.depositPayload(), + try Self.depositPayload() + ] + let viewModel = DepositOrderListViewModel() + await viewModel.reload(api: service, scenicId: 88, reset: true) + + let writeOffSuccess = await viewModel.writeOff(api: service, scenicId: 88, orderNumber: "DEP001") + let emptyReason = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: " ") + service.depositRefundError = APIError.serverCode(500, "退款失败") + let refundFailure = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: "游客取消") + + XCTAssertTrue(writeOffSuccess) + XCTAssertFalse(emptyReason) + XCTAssertFalse(refundFailure) + XCTAssertEqual(service.depositWriteOffNumbers, ["DEP001"]) + XCTAssertEqual(service.depositRefundCalls, [.init(orderNumber: "DEP001", reason: "游客取消")]) + XCTAssertEqual(service.depositListCalls.count, 2) + XCTAssertEqual(viewModel.orders.count, 2) + XCTAssertNil(viewModel.operatingOrderNumber) + XCTAssertEqual(viewModel.errorMessage, "退款失败") + } + + /// 测试押金详情和拍摄信息缺少门店时不请求接口。 + func testDepositDetailAndShootingSkipWithoutStore() async { + let service = LongTailMockOrderService() + let detailViewModel = DepositOrderDetailViewModel() + let shootingViewModel = DepositOrderShootingInfoViewModel() + + await detailViewModel.load(api: service, storeId: nil, orderNumber: "DEP001") + await shootingViewModel.load(api: service, storeId: nil, orderNumber: "DEP001", scenicSpotId: 901, photogUid: 42) + + XCTAssertTrue(service.storeDetailCalls.isEmpty) + XCTAssertTrue(service.storeShootingCalls.isEmpty) + XCTAssertEqual(detailViewModel.errorMessage, "当前账号缺少门店信息") + XCTAssertEqual(shootingViewModel.errorMessage, "当前账号缺少门店信息") + } + + /// 测试普通退款入口规则、金额校验、重复提交保护和成功提交参数。 + func testOrderRefundValidationAndSubmit() async throws { + let service = LongTailMockOrderService() + let viewModel = OrderRefundViewModel() + let refundable = try Self.orderEntity(orderStatus: 18, orderType: 4, actualPayAmount: "99.50") + let unavailable = try Self.orderEntity(orderStatus: 50, orderType: 4, actualPayAmount: "99.50") + + XCTAssertTrue(viewModel.canRefund(refundable)) + XCTAssertFalse(viewModel.canRefund(unavailable)) + viewModel.begin(item: refundable) + XCTAssertEqual(viewModel.amount, "99.50") + let missingReasonSuccess = await viewModel.submit(api: service, item: refundable) + XCTAssertFalse(missingReasonSuccess) + XCTAssertEqual(viewModel.errorMessage, "请输入退款原因") + + viewModel.reason = "部分退款" + viewModel.mode = .partial + viewModel.amount = "12.345" + let invalidAmountSuccess = await viewModel.submit(api: service, item: refundable) + XCTAssertFalse(invalidAmountSuccess) + XCTAssertEqual(viewModel.errorMessage, "请输入有效的退款金额") + + viewModel.amount = "120.00" + let overLimitSuccess = await viewModel.submit(api: service, item: refundable) + XCTAssertFalse(overLimitSuccess) + XCTAssertEqual(viewModel.errorMessage, "退款金额不能大于可退金额") + + viewModel.amount = "12.3" + let submitSuccess = await viewModel.submit(api: service, item: refundable) + XCTAssertTrue(submitSuccess) + XCTAssertEqual(service.orderRefundCalls, [ + .init(orderNumber: "STORE001", mode: .partial, amount: "12.30", reason: "部分退款") + ]) + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.submitting) + } + + /// 测试历史拍摄空订单不请求,成功后展示项目和媒体,失败后清空旧数据。 + func testHistoricalShootingLoadSuccessAndFailure() async throws { + let service = LongTailMockOrderService() + service.historyResponses = [try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")] + let viewModel = HistoricalShootingInfoViewModel() + + await viewModel.load(api: service, orderNumber: " ") + XCTAssertTrue(service.historyCalls.isEmpty) + XCTAssertEqual(viewModel.errorMessage, "订单号不能为空") + + await viewModel.load(api: service, orderNumber: "MT001") + XCTAssertEqual(viewModel.projectName, "多点旅拍") + XCTAssertEqual(viewModel.spots.first?.scenicSpotName, "湖心亭") + XCTAssertEqual(viewModel.spots.first?.files.count, 2) + + service.historyError = APIError.serverCode(500, "历史拍摄失败") + await viewModel.load(api: service, orderNumber: "MT001") + XCTAssertTrue(viewModel.spots.isEmpty) + XCTAssertEqual(viewModel.errorMessage, "历史拍摄失败") + } + + /// 测试任务上传空订单不请求,加载成功默认选中首个打卡点,加载失败清空状态。 + func testMultiTravelTaskUploadLoadsAndRepairsSpotSelection() async throws { + let service = LongTailMockOrderService() + service.verifiedSpotResponses = [ + try Self.verifiedSpots(), + [try Self.verifiedSpot(id: 902, name: "观景台")] + ] + let viewModel = MultiTravelTaskUploadViewModel() + + await viewModel.loadSpots(api: service) + XCTAssertTrue(service.verifiedSpotCalls.isEmpty) + + viewModel.orderNumber = "MT001" + await viewModel.loadSpots(api: service) + XCTAssertEqual(viewModel.selectedSpotId, 901) + viewModel.selectSpot(id: 901) + await viewModel.loadSpots(api: service) + XCTAssertEqual(viewModel.selectedSpotId, 902) + + service.verifiedSpotError = APIError.serverCode(500, "打卡点失败") + await viewModel.loadSpots(api: service) + XCTAssertTrue(viewModel.spots.isEmpty) + XCTAssertNil(viewModel.selectedSpotId) + XCTAssertEqual(viewModel.errorMessage, "打卡点失败") + } + + /// 测试任务上传提交校验和仅云盘文件直接提交。 + func testMultiTravelTaskUploadValidatesAndSubmitsCloudFiles() async throws { + let service = LongTailMockOrderService() + service.verifiedSpotResponses = [try Self.verifiedSpots()] + let uploader = LongTailMockUploader() + let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001") + + let missingScenicSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: nil) + XCTAssertFalse(missingScenicSuccess) + XCTAssertEqual(viewModel.errorMessage, "请先选择景区") + + await viewModel.loadSpots(api: service) + let missingFileSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88) + XCTAssertFalse(missingFileSuccess) + XCTAssertEqual(viewModel.errorMessage, "请至少选择一个素材文件") + + viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 7001, fileName: "cloud.jpg", fileType: 2, remark: "")]) + let submitSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88) + + XCTAssertTrue(submitSuccess) + XCTAssertTrue(viewModel.didSubmitSuccessfully) + XCTAssertEqual(service.uploadMaterialRequests, [ + MultiTravelUploadMaterialRequest( + orderNumber: "MT001", + scenicSpotId: 901, + cloudFile: [MultiTravelCloudFileItem(fileId: 7001)], + uploadFile: [] + ) + ]) + XCTAssertTrue(uploader.uploadTaskFiles.isEmpty) + } + + /// 测试任务上传本地文件先上传 OSS,上传失败不提交业务接口。 + func testMultiTravelTaskUploadUploadsLocalFilesBeforeSubmit() async throws { + let service = LongTailMockOrderService() + service.verifiedSpotResponses = [try Self.verifiedSpots()] + let uploader = LongTailMockUploader() + uploader.uploadTaskFileURLs = ["https://cdn.example.com/local.jpg"] + let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001") + await viewModel.loadSpots(api: service) + viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg") + + let success = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadTaskFiles.map(\.fileName), ["local.jpg"]) + XCTAssertEqual(service.uploadMaterialRequests.first?.uploadFile, [ + MultiTravelUploadFileItem(fileName: "local.jpg", fileUrl: "https://cdn.example.com/local.jpg") + ]) + + let failingService = LongTailMockOrderService() + failingService.verifiedSpotResponses = [try Self.verifiedSpots()] + let failingUploader = LongTailMockUploader() + failingUploader.uploadError = APIError.serverCode(500, "上传失败") + let failingViewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001") + await failingViewModel.loadSpots(api: failingService) + failingViewModel.addLocalFile(data: Data([1]), fileName: "bad.mp4") + + let failure = await failingViewModel.submit(api: failingService, uploadService: failingUploader, scenicId: 88) + + XCTAssertFalse(failure) + XCTAssertTrue(failingService.uploadMaterialRequests.isEmpty) + XCTAssertEqual(failingViewModel.errorMessage, "上传失败") + } + + /// 读取押金订单列表 fixture。 + private static func depositPayload() throws -> ListPayload { + try TestFixture.payload(ListPayload.self, named: "deposit_order_list_success") + } + + /// 构建最小普通订单实体,用于退款规则测试。 + private static func orderEntity(orderStatus: Int, orderType: Int, actualPayAmount: String) throws -> OrderEntity { + let json = """ + { + "order_number": "STORE001", + "order_status": "\(orderStatus)", + "order_type": "\(orderType)", + "actual_pay_amount": "\(actualPayAmount)", + "actual_refund_amount": "0", + "refund_amount": "0", + "order_type_label": "旅拍订单", + "order_status_name": "待退款" + } + """ + return try JSONDecoder().decode(OrderEntity.self, from: Data(json.utf8)) + } + + /// 读取已核销打卡点 fixture。 + private static func verifiedSpots() throws -> [MultiTravelVerifiedScenicSpotItem] { + try TestFixture.payload([MultiTravelVerifiedScenicSpotItem].self, named: "multi_travel_verified_spots_success") + } + + /// 构建已核销打卡点测试实体。 + private static func verifiedSpot(id: Int, name: String) throws -> MultiTravelVerifiedScenicSpotItem { + let json = #"{"id":"\#(id)","name":"\#(name)"}"# + return try JSONDecoder().decode(MultiTravelVerifiedScenicSpotItem.self, from: Data(json.utf8)) + } +} + +/// 长尾订单 URLSession 测试替身,按调用顺序返回响应并记录请求。 +private final class LongTailRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + init(responses: [Data]) { + self.responses = responses + } + + /// 返回队列中的下一个响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data(#"{"code":100000,"msg":"success"}"#.utf8) : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} + +@MainActor +/// 长尾订单服务测试替身,记录押金、退款和历史拍摄调用。 +private final class LongTailMockOrderService: OrderServing { + struct DepositListCall: Equatable { + let scenicId: Int + let page: Int + let pageSize: Int + } + + struct DepositRefundCall: Equatable { + let orderNumber: String + let reason: String + } + + struct OrderRefundCall: Equatable { + let orderNumber: String + let mode: OrderRefundMode + let amount: String + let reason: String + } + + var depositListResponses: [ListPayload] = [] + var detailResponses: [StoreOrderDetailResponse] = [] + var shootingResponses: [StoreOrderShootingDetailResponse] = [] + var historyResponses: [MultiTravelShootHistoryResponse] = [] + var verifiedSpotResponses: [[MultiTravelVerifiedScenicSpotItem]] = [] + var depositWriteOffError: Error? + var depositRefundError: Error? + var orderRefundError: Error? + var historyError: Error? + var verifiedSpotError: Error? + var uploadMaterialError: Error? + + private(set) var depositListCalls: [DepositListCall] = [] + private(set) var depositWriteOffNumbers: [String] = [] + private(set) var depositRefundCalls: [DepositRefundCall] = [] + private(set) var orderRefundCalls: [OrderRefundCall] = [] + private(set) var storeDetailCalls: [(storeId: Int, orderNumber: String)] = [] + private(set) var storeShootingCalls: [(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int)] = [] + private(set) var historyCalls: [String] = [] + private(set) var verifiedSpotCalls: [String] = [] + private(set) var uploadMaterialRequests: [MultiTravelUploadMaterialRequest] = [] + + /// 基础订单列表接口在长尾测试中不使用。 + func orderList( + scenicId: Int, + page: Int, + pageSize: Int, + orderStatus: Int?, + userPhone: String?, + startTime: String?, + endTime: String?, + isRefined: Int?, + isScenicAdmin: Bool + ) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 核销列表接口在长尾测试中不使用。 + func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 普通核销接口在长尾测试中不使用。 + func writeOff(orderNumber: String) async throws {} + + /// 返回门店订单详情。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { + storeDetailCalls.append((storeId: storeId, orderNumber: orderNumber)) + return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() + } + + /// 返回押金订单列表。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + depositListCalls.append(.init(scenicId: scenicId, page: page, pageSize: pageSize)) + return depositListResponses.isEmpty ? ListPayload(total: 0, list: []) : depositListResponses.removeFirst() + } + + /// 记录押金核销订单号。 + func depositOrderWriteOff(orderNumber: String) async throws { + depositWriteOffNumbers.append(orderNumber) + if let depositWriteOffError { + throw depositWriteOffError + } + } + + /// 记录押金退款参数。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws { + depositRefundCalls.append(.init(orderNumber: orderNumber, reason: refundReason)) + if let depositRefundError { + throw depositRefundError + } + } + + /// 返回拍摄信息详情。 + func storeOrderShootingDetail( + storeId: Int, + orderNumber: String, + scenicSpotId: Int, + photogUid: Int + ) async throws -> StoreOrderShootingDetailResponse { + storeShootingCalls.append((storeId: storeId, orderNumber: orderNumber, scenicSpotId: scenicSpotId, photogUid: photogUid)) + return shootingResponses.isEmpty ? try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") : shootingResponses.removeFirst() + } + + /// 记录普通退款参数。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { + orderRefundCalls.append(.init(orderNumber: orderNumber, mode: refundType, amount: refundAmount, reason: refundReason)) + if let orderRefundError { + throw orderRefundError + } + } + + /// 返回历史拍摄信息。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + historyCalls.append(orderNumber) + if let historyError { + throw historyError + } + return historyResponses.isEmpty ? try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") : historyResponses.removeFirst() + } + + /// 返回已核销打卡点列表。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + verifiedSpotCalls.append(orderNumber) + if let verifiedSpotError { + throw verifiedSpotError + } + return verifiedSpotResponses.isEmpty ? [] : verifiedSpotResponses.removeFirst() + } + + /// 记录多点旅拍素材提交请求。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { + uploadMaterialRequests.append(request) + if let uploadMaterialError { + throw uploadMaterialError + } + } +} + +@MainActor +/// 长尾订单 OSS 上传测试替身,只记录任务附件上传。 +private final class LongTailMockUploader: OSSUploadServing { + struct UploadTaskFileCall: Equatable { + let fileName: String + let fileType: Int + let scenicId: Int + let dataSize: Int + } + + var uploadTaskFileURLs: [String] = [] + var uploadError: Error? + private(set) var uploadTaskFiles: [UploadTaskFileCall] = [] + + /// 返回预设任务附件 URL。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + uploadTaskFiles.append(.init(fileName: fileName, fileType: fileType, scenicId: scenicId, dataSize: data.count)) + if let uploadError { + throw uploadError + } + onProgress(100) + return uploadTaskFileURLs.isEmpty ? "https://cdn.example.com/\(fileName)" : uploadTaskFileURLs.removeFirst() + } + + /// 其他上传入口在本测试中不使用。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 其他上传入口在本测试中不使用。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +/// 提取请求 query 方便断言。 +private func longTailQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +/// 将 JSON body 解成字典方便断言。 +private func longTailBodyObject(from request: URLRequest) throws -> [String: Any] { + let data = try XCTUnwrap(request.httpBody) + let object = try JSONSerialization.jsonObject(with: data) + return try XCTUnwrap(object as? [String: Any]) +} diff --git a/suixinkan_iosTests/OrderNumberParserTests.swift b/suixinkan_iosTests/OrderNumberParserTests.swift new file mode 100644 index 0000000..227938d --- /dev/null +++ b/suixinkan_iosTests/OrderNumberParserTests.swift @@ -0,0 +1,38 @@ +// +// OrderNumberParserTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +/// 订单号解析器测试,覆盖扫码内容中的常见订单号格式。 +final class OrderNumberParserTests: XCTestCase { + /// 测试从 URL query 中解析 order_number。 + func testParseOrderNumberFromURLQuery() { + XCTAssertEqual( + OrderNumberParser.parse("https://example.com/check?foo=1&order_number=ORD123456"), + "ORD123456" + ) + } + + /// 测试从 key-value 文本中解析订单号。 + func testParseOrderNumberFromKeyValueText() { + XCTAssertEqual(OrderNumberParser.parse("order_number=ORD123456"), "ORD123456") + XCTAssertEqual(OrderNumberParser.parse("order-number:ORD654321"), "ORD654321") + } + + /// 测试从纯文本中解析订单号。 + func testParsePlainOrderNumberText() { + XCTAssertEqual(OrderNumberParser.parse(" VERIFY001 "), "VERIFY001") + } + + /// 测试无效内容不会返回订单号。 + func testParseInvalidTextReturnsNil() { + XCTAssertNil(OrderNumberParser.parse("")) + XCTAssertNil(OrderNumberParser.parse("abc def")) + XCTAssertNil(OrderNumberParser.parse("12345")) + } +} diff --git a/suixinkan_iosTests/OrdersViewModelTests.swift b/suixinkan_iosTests/OrdersViewModelTests.swift new file mode 100644 index 0000000..870b183 --- /dev/null +++ b/suixinkan_iosTests/OrdersViewModelTests.swift @@ -0,0 +1,392 @@ +// +// OrdersViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 订单 ViewModel 测试,覆盖列表分页、筛选条件和手动核销状态。 +final class OrdersViewModelTests: XCTestCase { + /// 测试无景区时清空订单管理旧数据且不请求接口。 + func testStoreOrdersClearStaleDataWhenScenicIsMissing() async throws { + let api = MockOrderService() + api.storeResponses = [try TestFixture.payload(ListPayload.self, named: "order_v2_success")] + let viewModel = OrdersViewModel() + + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) + XCTAssertFalse(viewModel.storeOrders.isEmpty) + + try await viewModel.reloadStoreOrders(api: api, scenicId: nil, roleId: nil) + + XCTAssertTrue(viewModel.storeOrders.isEmpty) + XCTAssertEqual(viewModel.storeTotal, 0) + XCTAssertEqual(viewModel.storePage, 1) + XCTAssertFalse(viewModel.storeHasMore) + XCTAssertEqual(api.orderListCalls.count, 1) + } + + /// 测试订单管理最后一页不会继续请求。 + func testStoreOrdersDoNotRequestMoreAfterLastPage() async throws { + let api = MockOrderService() + api.storeResponses = [try TestFixture.payload(ListPayload.self, named: "order_v2_success")] + let viewModel = OrdersViewModel() + + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) + try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, roleId: nil) + + XCTAssertEqual(viewModel.storeOrders.count, 2) + XCTAssertEqual(viewModel.storeTotal, 2) + XCTAssertEqual(viewModel.storePage, 1) + XCTAssertFalse(viewModel.storeHasMore) + XCTAssertEqual(api.orderListCalls.count, 1) + } + + /// 测试订单管理筛选参数会传入服务层并重置到第一页。 + func testStoreOrderFilterUsesExpectedQueries() async throws { + let api = MockOrderService() + api.storeResponses = [ + try TestFixture.payload(ListPayload.self, named: "order_v2_success"), + try TestFixture.payload(ListPayload.self, named: "order_v2_success") + ] + let viewModel = OrdersViewModel() + viewModel.selectedStatus = -1 + viewModel.searchPhone = " 13800000000 " + viewModel.filterStartDate = Self.date("2026-05-01") + viewModel.filterEndDate = Self.date("2026-05-31") + + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) + + viewModel.selectedStatus = 30 + viewModel.searchPhone = " " + viewModel.filterStartDate = nil + viewModel.filterEndDate = nil + try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: 53) + + XCTAssertEqual(api.orderListCalls.count, 2) + XCTAssertEqual(api.orderListCalls[0].page, 1) + XCTAssertEqual(api.orderListCalls[0].isRefined, 1) + XCTAssertEqual(api.orderListCalls[0].userPhone, "13800000000") + XCTAssertNil(api.orderListCalls[0].orderStatus) + XCTAssertEqual(api.orderListCalls[0].startTime, "2026-05-01") + XCTAssertEqual(api.orderListCalls[0].endTime, "2026-05-31") + XCTAssertFalse(api.orderListCalls[0].isScenicAdmin) + XCTAssertEqual(api.orderListCalls[1].orderStatus, 30) + XCTAssertNil(api.orderListCalls[1].isRefined) + XCTAssertNil(api.orderListCalls[1].userPhone) + XCTAssertTrue(api.orderListCalls[1].isScenicAdmin) + XCTAssertEqual(viewModel.storePage, 1) + } + + /// 测试无景区时清空核销订单旧数据且不请求接口。 + func testWriteOffOrdersClearStaleDataWhenScenicIsMissing() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + + try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88, storeId: 66) + XCTAssertFalse(viewModel.writeOffOrders.isEmpty) + + try await viewModel.reloadWriteOffOrders(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.writeOffOrders.isEmpty) + XCTAssertEqual(viewModel.writeOffTotal, 0) + XCTAssertEqual(viewModel.writeOffPage, 1) + XCTAssertFalse(viewModel.writeOffHasMore) + XCTAssertEqual(api.writeOffListCalls.count, 1) + XCTAssertEqual(api.writeOffListCalls[0].storeId, 66) + } + + /// 测试核销订单最后一页不会继续请求。 + func testWriteOffOrdersDoNotRequestMoreAfterLastPage() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + + try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) + try await viewModel.loadMoreWriteOffOrders(api: api, scenicId: 88) + + XCTAssertEqual(viewModel.writeOffOrders.count, 1) + XCTAssertEqual(viewModel.writeOffTotal, 1) + XCTAssertEqual(viewModel.writeOffPage, 1) + XCTAssertFalse(viewModel.writeOffHasMore) + XCTAssertEqual(api.writeOffListCalls.count, 1) + } + + /// 测试核销成功后刷新列表并重置分页状态。 + func testVerifyRefreshesWriteOffOrdersAndResetsPagingState() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + + try await viewModel.verify(api: api, scenicId: 88, storeId: 66, orderNumber: "VERIFY001") + + XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"]) + XCTAssertEqual(api.writeOffListCalls.count, 1) + XCTAssertEqual(api.writeOffListCalls[0].scenicId, 88) + XCTAssertEqual(api.writeOffListCalls[0].storeId, 66) + XCTAssertEqual(api.writeOffListCalls[0].page, 1) + XCTAssertEqual(viewModel.writeOffOrders.count, 1) + XCTAssertEqual(viewModel.writeOffPage, 1) + XCTAssertFalse(viewModel.isVerifying) + XCTAssertNil(viewModel.currentVerifyingOrderNumber) + } + + /// 测试扫码命中当前核销列表时返回可高亮订单。 + func testScanResultMatchesCurrentWriteOffOrder() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) + + let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "https://example.com/scan?order_number=VERIFY001")) + + XCTAssertEqual(result.orderNumber, "VERIFY001") + XCTAssertEqual(result.matched?.orderNumber, "VERIFY001") + } + + /// 测试扫码未命中当前列表时仍保留解析出的订单号。 + func testScanResultKeepsParsedOrderNumberWhenCurrentListDoesNotContainIt() async throws { + let api = MockOrderService() + api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] + let viewModel = OrdersViewModel() + try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) + + let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "order-number=VERIFY999")) + + XCTAssertEqual(result.orderNumber, "VERIFY999") + XCTAssertNil(result.matched) + } + + /// 测试重复核销提交会被忽略。 + func testVerifyIgnoresDuplicateSubmitWhileAlreadyVerifying() async throws { + let api = MockOrderService() + let viewModel = OrdersViewModel() + viewModel.markVerifyingForTests(orderNumber: "VERIFY001") + + try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY002") + + XCTAssertTrue(api.verifiedOrderNumbers.isEmpty) + XCTAssertTrue(api.writeOffListCalls.isEmpty) + XCTAssertTrue(viewModel.isVerifying) + XCTAssertEqual(viewModel.currentVerifyingOrderNumber, "VERIFY001") + } + + /// 测试核销失败时清理提交状态且不刷新列表。 + func testVerifyFailureClearsSubmittingStateWithoutRefreshingList() async throws { + let api = MockOrderService() + api.verifyError = APIError.httpStatus(500, "server error") + let viewModel = OrdersViewModel() + + do { + try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001") + XCTFail("Expected verify to fail") + } catch APIError.httpStatus(let statusCode, _) { + XCTAssertEqual(statusCode, 500) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"]) + XCTAssertTrue(api.writeOffListCalls.isEmpty) + XCTAssertFalse(viewModel.isVerifying) + XCTAssertNil(viewModel.currentVerifyingOrderNumber) + } + + /// 测试订单 API 会按角色选择正确接口路径。 + func testOrdersAPIUsesExpectedEndpointForRole() async throws { + let session = RecordingURLSession(data: try TestFixture.data(named: "order_v2_success")) + let api = OrdersAPI(client: APIClient(session: session)) + + _ = try await api.orderList( + scenicId: 88, + page: 1, + pageSize: 20, + orderStatus: 30, + userPhone: "13800000000", + startTime: "2026-05-01", + endTime: "2026-05-31", + isRefined: nil, + isScenicAdmin: true + ) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/order/list") + let query = queryItems(from: request) + XCTAssertEqual(query["scenic_id"], "88") + XCTAssertEqual(query["order_status"], "30") + XCTAssertEqual(query["user_phone"], "13800000000") + XCTAssertEqual(query["start_time"], "2026-05-01") + XCTAssertEqual(query["end_time"], "2026-05-31") + } + + private static func date(_ text: String) -> Date { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: text)! + } +} + +@MainActor +/// 订单服务测试替身,记录请求参数并返回预设响应。 +private final class MockOrderService: OrderServing { + struct OrderListCall: Equatable { + let scenicId: Int + let page: Int + let pageSize: Int + let orderStatus: Int? + let userPhone: String? + let startTime: String? + let endTime: String? + let isRefined: Int? + let isScenicAdmin: Bool + } + + struct WriteOffListCall: Equatable { + let scenicId: Int + let storeId: Int? + let page: Int + let pageSize: Int + } + + var storeResponses: [ListPayload] = [] + var writeOffResponses: [ListPayload] = [] + var detailResponses: [StoreOrderDetailResponse] = [] + var verifyError: Error? + private(set) var orderListCalls: [OrderListCall] = [] + private(set) var writeOffListCalls: [WriteOffListCall] = [] + private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = [] + private(set) var verifiedOrderNumbers: [String] = [] + + /// 返回测试订单管理列表。 + func orderList( + scenicId: Int, + page: Int, + pageSize: Int, + orderStatus: Int?, + userPhone: String?, + startTime: String?, + endTime: String?, + isRefined: Int?, + isScenicAdmin: Bool + ) async throws -> ListPayload { + orderListCalls.append( + OrderListCall( + scenicId: scenicId, + page: page, + pageSize: pageSize, + orderStatus: orderStatus, + userPhone: userPhone, + startTime: startTime, + endTime: endTime, + isRefined: isRefined, + isScenicAdmin: isScenicAdmin + ) + ) + return storeResponses.isEmpty ? ListPayload(total: 0, list: []) : storeResponses.removeFirst() + } + + /// 返回测试核销订单列表。 + func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { + writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize)) + return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst() + } + + /// 记录测试核销订单号。 + func writeOff(orderNumber: String) async throws { + verifiedOrderNumbers.append(orderNumber) + if let verifyError { + throw verifyError + } + } + + /// 返回测试订单详情。 + func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { + detailCalls.append((storeId: storeId, orderNumber: orderNumber)) + return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() + } + + /// 押金订单列表接口在基础订单测试中不会被调用。 + func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { + XCTFail("depositOrderList should not be called") + return ListPayload(total: 0, list: []) + } + + /// 押金核销接口在基础订单测试中不会被调用。 + func depositOrderWriteOff(orderNumber: String) async throws { + XCTFail("depositOrderWriteOff should not be called") + } + + /// 押金退款接口在基础订单测试中不会被调用。 + func depositOrderRefund(orderNumber: String, refundReason: String) async throws { + XCTFail("depositOrderRefund should not be called") + } + + /// 拍摄信息接口在基础订单测试中不会被调用。 + func storeOrderShootingDetail( + storeId: Int, + orderNumber: String, + scenicSpotId: Int, + photogUid: Int + ) async throws -> StoreOrderShootingDetailResponse { + XCTFail("storeOrderShootingDetail should not be called") + return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") + } + + /// 普通退款接口在基础订单测试中不会被调用。 + func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { + XCTFail("orderRefund should not be called") + } + + /// 历史拍摄接口在基础订单测试中不会被调用。 + func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { + XCTFail("multiTravelShootHistory should not be called") + return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") + } + + /// 已核销打卡点接口在基础订单测试中不会被调用。 + func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { + XCTFail("multiTravelVerifiedScenicSpotList should not be called") + return [] + } + + /// 多点旅拍素材提交接口在基础订单测试中不会被调用。 + func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { + XCTFail("multiTravelUploadMaterial should not be called") + } +} + +/// URLSession 测试替身,记录请求并返回固定响应。 +private final class RecordingURLSession: URLSessionProtocol { + let data: Data + var statusCode: Int + private(set) var requests: [URLRequest] = [] + + init(data: Data, statusCode: Int = 200) { + self.data = data + self.statusCode = statusCode + } + + /// 返回固定数据和 HTTP 响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + ) + } +} + +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkan_iosTests/PaymentViewModelTests.swift b/suixinkan_iosTests/PaymentViewModelTests.swift new file mode 100644 index 0000000..04cdd25 --- /dev/null +++ b/suixinkan_iosTests/PaymentViewModelTests.swift @@ -0,0 +1,108 @@ +// +// PaymentViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 收款模块测试,覆盖收款码、动态二维码、轮询和记录分组。 +final class PaymentViewModelTests: XCTestCase { + /// 测试无景区时清空二维码且不请求接口。 + func testLoadPayCodeWithoutScenicClearsState() async { + let api = PaymentMockAPI() + let viewModel = PaymentCollectionViewModel() + viewModel.staticPayUrl = "old" + + await viewModel.loadPayCode(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.staticPayUrl.isEmpty) + XCTAssertEqual(api.payCodeScenicIds, []) + XCTAssertEqual(viewModel.errorMessage, "请先选择景区") + } + + /// 测试收款码接口加载后生成当前二维码 URL。 + func testLoadPayCodeUsesScenicIdAndAppliesStaticURL() async { + let api = PaymentMockAPI() + api.payCodeResponse = PayCodeResponse(staticPayUrl: "https://pay.example.com/static", dynamicPayUrl: "https://pay.example.com/dynamic") + let viewModel = PaymentCollectionViewModel() + + await viewModel.loadPayCode(api: api, scenicId: 9) + + XCTAssertEqual(api.payCodeScenicIds, [9]) + XCTAssertEqual(viewModel.currentPayUrl, "https://pay.example.com/static") + XCTAssertTrue(viewModel.hasStaticPayCode) + } + + /// 测试设置金额会校验金额并生成动态收款 URL。 + func testApplyDynamicAmountBuildsDynamicURL() async { + let viewModel = PaymentCollectionViewModel() + viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9" + viewModel.amountText = "12.345" + viewModel.remarkText = "茶水" + + XCTAssertTrue(viewModel.applyDynamicAmount()) + XCTAssertTrue(viewModel.currentPayUrl.contains("amount=12.35")) + XCTAssertTrue(viewModel.currentPayUrl.contains("remark=")) + XCTAssertEqual(viewModel.status, .waiting) + } + + /// 测试轮询命中新收款记录后进入成功态。 + func testPollingFindsNewPaymentRecord() async { + let api = PaymentMockAPI() + api.recordResponses = [ + PaymentCollectionRecordResponse(list: [ + PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00") + ]), + PaymentCollectionRecordResponse(list: [ + PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00"), + PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01") + ]) + ] + let viewModel = PaymentCollectionViewModel() + viewModel.amountText = "12.35" + await viewModel.primePaymentRecords(api: api, scenicId: 9) + + await viewModel.pollUntilPaymentDetected(api: api, scenicId: 9, maxAttempts: 1, intervalNanoseconds: 0) + + XCTAssertEqual(viewModel.status, .success(PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01"))) + } + + /// 测试后端缺少日汇总时能从列表兜底生成分组。 + func testRecordGroupsFallbackFromList() { + let response = PaymentCollectionRecordResponse(list: [ + PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-21", createTime: "10:00"), + PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.00", createDate: "2026-06-21", createTime: "10:01") + ]) + + let groups = PaymentCollectionRecordViewModel.makeGroups(from: response) + + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups[0].analyse.orderCount, 2) + XCTAssertEqual(groups[0].analyse.orderAmountSum, "20") + } +} + +@MainActor +/// 收款 API 测试替身。 +private final class PaymentMockAPI: PaymentServing { + var payCodeResponse = PayCodeResponse() + var recordResponses: [PaymentCollectionRecordResponse] = [] + private(set) var payCodeScenicIds: [Int] = [] + private(set) var recordScenicIds: [Int] = [] + + /// 记录景区 ID 并返回收款码响应。 + func payCode(scenicId: Int) async throws -> PayCodeResponse { + payCodeScenicIds.append(scenicId) + return payCodeResponse + } + + /// 记录景区 ID 并返回下一份收款记录响应。 + func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse { + recordScenicIds.append(scenicId) + return recordResponses.isEmpty ? PaymentCollectionRecordResponse() : recordResponses.removeFirst() + } +} diff --git a/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift b/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift new file mode 100644 index 0000000..da9e55b --- /dev/null +++ b/suixinkan_iosTests/PilotCertification/PilotCertificationTests.swift @@ -0,0 +1,277 @@ +// +// PilotCertificationTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 飞手认证测试,覆盖接口编码、加载、验证码、校验和提交流程。 +final class PilotCertificationTests: XCTestCase { + func testAPIsUseExpectedPathsAndBodies() async throws { + let session = PilotRecordingURLSession(responses: [ + try TestFixture.data(named: "flyer_detail_success"), + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = PilotCertificationAPI(client: APIClient(session: session)) + + let detail = try await api.flyerDetail() + XCTAssertEqual(detail.id, 801) + XCTAssertEqual(detail.certificateNo, "UAS-2026-001") + var request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/flyer/detail") + + try await api.flyerSendCode(phone: "13800000000") + request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/flyer/send-code") + XCTAssertEqual(try pilotJSONBody(from: request)["phone"] as? String, "13800000000") + + try await api.flyerApply(applyRequest()) + request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/flyer/apply") + var json = try pilotJSONBody(from: request) + XCTAssertEqual(json["name"] as? String, "航拍摄影师") + XCTAssertEqual(json["certificate_no"] as? String, "UAS-2026-001") + XCTAssertEqual(json["drone_sn"] as? String, "SN20260523001") + + try await api.flyerEdit(editRequest()) + request = try XCTUnwrap(session.requests.last) + XCTAssertEqual(request.url?.path, "/api/app/flyer/edit") + json = try pilotJSONBody(from: request) + XCTAssertEqual(json["id"] as? Int, 801) + XCTAssertEqual(json["contact_phone"] as? String, "13800000001") + } + + func testViewModelLoadSendCodeAndCountdown() async throws { + let api = PilotMock() + api.detailResponses = [try TestFixture.payload(FlyerDetailResponse.self, named: "flyer_detail_success")] + let realName = PilotRealNameMock(response: try TestFixture.payload(RealNameInfoResponse.self, named: "real_name_info_success")) + let viewModel = PilotCertificationViewModel() + + await viewModel.load(api: api, realNameAPI: realName) + + XCTAssertEqual(viewModel.name, "航拍摄影师") + XCTAssertEqual(viewModel.certType, .caac) + XCTAssertEqual(viewModel.droneSerialNo, "SN20260523001") + XCTAssertTrue(viewModel.isRealNameVerified) + XCTAssertEqual(viewModel.latestAuditLog?.operatorName, "审核员") + + let codeViewModel = PilotCertificationViewModel() + fillValidForm(codeViewModel) + try await codeViewModel.sendCode(api: api) + XCTAssertEqual(api.sendCodePhones, ["13800000000"]) + XCTAssertEqual(codeViewModel.countdown, 60) + codeViewModel.tickCountdown() + XCTAssertEqual(codeViewModel.countdown, 59) + + api.sendCodeError = PilotTestError.sample + codeViewModel.phone = "13800000001" + while codeViewModel.countdown > 0 { codeViewModel.tickCountdown() } + await XCTAssertThrowsErrorAsync(try await codeViewModel.sendCode(api: api)) + XCTAssertEqual(codeViewModel.countdown, 0) + } + + func testValidationCoversRequiredFieldsPhoneImageAndDates() { + let viewModel = PilotCertificationViewModel() + + XCTAssertEqual(viewModel.validationMessage, "请输入飞手昵称") + fillValidForm(viewModel) + viewModel.phone = "123" + XCTAssertEqual(viewModel.validationMessage, "请输入有效的手机号码") + + fillValidForm(viewModel) + viewModel.certImageUrl = "" + XCTAssertEqual(viewModel.validationMessage, "请选择证件图片") + + fillValidForm(viewModel) + viewModel.endDate = Date(timeInterval: -86_400, since: viewModel.startDate) + XCTAssertEqual(viewModel.validationMessage, "截至日期不能早于起始日期") + } + + func testSubmitUploadsAndAppliesThenClearsPendingImage() async throws { + let api = PilotMock() + let uploader = PilotUploadMock() + let viewModel = PilotCertificationViewModel() + fillValidForm(viewModel) + viewModel.certImageUrl = "" + viewModel.prepareCertificateImage(data: Data([1, 2, 3]), fileName: "cert.jpg") + uploader.url = "https://cdn.example.com/pilot/cert.jpg" + + try await viewModel.submit(api: api, uploader: uploader, scenicId: 88) + + XCTAssertEqual(uploader.uploads.first?.fileName, "cert.jpg") + XCTAssertEqual(uploader.uploads.first?.scenicId, 88) + XCTAssertEqual(api.applyRequests.first?.certificateImage, "https://cdn.example.com/pilot/cert.jpg") + XCTAssertNil(viewModel.pendingCertificateImageData) + } + + func testRejectedSubmitUsesEditAndFailureKeepsForm() async throws { + let api = PilotMock() + api.detailResponses = [ + FlyerDetailResponse(id: 801, name: "旧资料", status: 3, certificateImage: "https://cdn.example.com/old.jpg") + ] + let viewModel = PilotCertificationViewModel() + await viewModel.load(api: api, realNameAPI: PilotRealNameMock()) + fillValidForm(viewModel) + + try await viewModel.submit(api: api, uploader: PilotUploadMock(), scenicId: 88) + XCTAssertEqual(api.editRequests.first?.id, 801) + + let failingUploader = PilotUploadMock() + failingUploader.error = PilotTestError.sample + viewModel.certImageUrl = "" + viewModel.prepareCertificateImage(data: Data([9]), fileName: "new.jpg") + await XCTAssertThrowsErrorAsync(try await viewModel.submit(api: api, uploader: failingUploader, scenicId: 88)) + XCTAssertEqual(viewModel.pendingCertificateFileName, "new.jpg") + } +} + +@MainActor +private final class PilotMock: PilotCertificationServing { + var detailResponses: [FlyerDetailResponse] = [] + var sendCodePhones: [String] = [] + var applyRequests: [FlyerApplyRequest] = [] + var editRequests: [FlyerEditRequest] = [] + var sendCodeError: Error? + + func flyerDetail() async throws -> FlyerDetailResponse { + detailResponses.isEmpty ? FlyerDetailResponse() : detailResponses.removeFirst() + } + + func flyerSendCode(phone: String) async throws { + if let sendCodeError { throw sendCodeError } + sendCodePhones.append(phone) + } + + func flyerApply(_ request: FlyerApplyRequest) async throws { + applyRequests.append(request) + } + + func flyerEdit(_ request: FlyerEditRequest) async throws { + editRequests.append(request) + } +} + +private struct PilotRealNameMock: PilotRealNameServing { + var response = RealNameInfoResponse(realNameInfo: nil) + + func realNameInfo() async throws -> RealNameInfoResponse { + response + } +} + +@MainActor +private final class PilotUploadMock: OSSUploadServing { + var url = "https://cdn.example.com/pilot/cert.jpg" + var error: Error? + var uploads: [(fileName: String, scenicId: Int)] = [] + + func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let error { throw error } + uploads.append((fileName, scenicId)) + onProgress(100) + return url + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +private final class PilotRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + init(responses: [Data]) { + self.responses = responses + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? try TestFixture.data(named: "empty_success") : responses.removeFirst() + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +private enum PilotTestError: LocalizedError { + case sample + + var errorDescription: String? { "测试错误" } +} + +private func fillValidForm(_ viewModel: PilotCertificationViewModel) { + viewModel.name = "航拍摄影师" + viewModel.certType = .caac + viewModel.certNo = "UAS-2026-001" + viewModel.certImageUrl = "https://cdn.example.com/flyer/cert.jpg" + viewModel.startDate = Date(timeIntervalSince1970: 1_767_225_600) + viewModel.endDate = Date(timeIntervalSince1970: 1_830_384_000) + viewModel.droneModel = "DJI Mini" + viewModel.droneSerialNo = "SN20260523001" + viewModel.phone = "13800000000" + viewModel.verifyCode = "123456" +} + +private func applyRequest() -> FlyerApplyRequest { + FlyerApplyRequest( + name: "航拍摄影师", + realnameStatus: 1, + certificateType: 2, + certificateNo: "UAS-2026-001", + certificateStartDate: "2026-01-01", + certificateEndDate: "2028-01-01", + certificateImage: "https://cdn.example.com/flyer/cert.jpg", + droneModel: "DJI Mini", + droneSn: "SN20260523001", + contactPhone: "13800000000", + code: "123456" + ) +} + +private func editRequest() -> FlyerEditRequest { + FlyerEditRequest( + id: 801, + name: "航拍摄影师", + realnameStatus: 1, + certificateType: 2, + certificateNo: "UAS-2026-001", + certificateStartDate: "2026-01-01", + certificateEndDate: "2028-01-01", + certificateImage: "https://cdn.example.com/flyer/cert.jpg", + droneModel: "DJI Mini", + droneSn: "SN20260523002", + contactPhone: "13800000001", + code: "654321" + ) +} + +private func pilotJSONBody(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + XCTFail("Expected async expression to throw", file: file, line: line) + } catch { + // Expected path. + } +} diff --git a/suixinkan_iosTests/ProfileSecondaryPagesTests.swift b/suixinkan_iosTests/ProfileSecondaryPagesTests.swift new file mode 100644 index 0000000..8be9ad4 --- /dev/null +++ b/suixinkan_iosTests/ProfileSecondaryPagesTests.swift @@ -0,0 +1,393 @@ +// +// ProfileSecondaryPagesTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import UIKit +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 个人中心二级页面测试,覆盖设置、账号切换和实名认证基础逻辑。 +final class ProfileSecondaryPagesTests: XCTestCase { + /// 测试设置页版本号展示规则。 + func testSettingsVersionTextUsesBuildNumberWhenNeeded() { + XCTAssertEqual( + SettingsDisplayPolicy.versionText(infoDictionary: [ + "CFBundleShortVersionString": "1.2", + "CFBundleVersion": "45" + ]), + "1.2.45" + ) + XCTAssertEqual( + SettingsDisplayPolicy.versionText(infoDictionary: [ + "CFBundleShortVersionString": "1.2.3", + "CFBundleVersion": "45" + ]), + "1.2.3" + ) + XCTAssertEqual(SettingsDisplayPolicy.versionText(infoDictionary: nil), "1.0.0") + } + + /// 测试协议页面映射到预期 H5 路径。 + func testAgreementPagesUseExpectedPaths() { + let pages: [(AgreementPage, String, String)] = [ + (.about, "关于我们", "/h5/app/about-us"), + (.userAgreement, "用户协议", "/h5/app/user-agreement"), + (.privacyPolicy, "隐私政策", "/h5/app/privacy-policy"), + (.walletUserNotice, "钱包用户须知", "/h5/app/wallet-user-notice"), + (.walletPrivacy, "钱包隐私政策", "/h5/app/wallet-privacy") + ] + + for (page, title, path) in pages { + XCTAssertEqual(page.title, title) + XCTAssertEqual(page.id, title) + XCTAssertEqual(page.url.path, path) + } + } + + /// 测试账号切换列表接口使用旧工程一致的 v9 accounts 路径。 + func testSwitchableAccountsUsesExpectedEndpoint() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "v9_login_multi_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + + let response = try await api.switchableAccounts() + + XCTAssertEqual(response.accounts.count, 2) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/v9/accounts") + } + + /// 测试账号切换 ViewModel 能加载账号并默认选中第一项。 + func testAccountSwitchViewModelLoadsAccounts() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "v9_login_multi_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + let viewModel = AccountSwitchViewModel() + + try await viewModel.load(api: api) + + XCTAssertEqual(viewModel.accounts.map(\.businessUserId), [101, 201]) + XCTAssertEqual(viewModel.selectedAccount?.businessUserId, 101) + XCTAssertFalse(viewModel.loading) + } + + /// 测试账号切换提交使用 set-user 接口和账号对应请求体。 + func testAccountSwitchViewModelSubmitsSetUser() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "v9_set_store_user_success") + ]) + let api = AuthAPI(client: APIClient(session: session)) + let account = AccountSwitchAccount( + accountType: V9StoreUser.accountTypeValue, + businessUserId: 201, + title: "示例门店", + subtitle: "示例景区", + phone: "13800138000", + avatar: "", + scenicName: "示例景区", + storeId: 20, + storeName: "示例门店", + scenicId: 10, + isCurrent: false + ) + let viewModel = AccountSwitchViewModel() + + let response = try await viewModel.switchAccount(account, api: api) + + XCTAssertFalse(response.token.isEmpty) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/app/v9/set-user") + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]) + XCTAssertEqual(json["store_user_id"] as? Int, 201) + XCTAssertNil(json["ss_user_id"] as? Int) + } + + /// 测试实名认证信息解码兼容字符串数字字段。 + func testRealNameInfoDecodesLossyFields() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "real_name_info_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + + let response = try await api.realNameInfo() + let info = try XCTUnwrap(response.realNameInfo) + + XCTAssertEqual(info.realName, "测试摄影师") + XCTAssertEqual(info.idCardNo, "320100********0012") + XCTAssertTrue(info.verified) + XCTAssertFalse(info.isLongValid) + XCTAssertEqual(info.auditStatus, 2) + XCTAssertEqual(info.auditorId, 7) + XCTAssertEqual(info.auditor?.name, "审核员") + XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/real-name/info") + } + + /// 测试实名认证短信和提交接口路径及请求体。 + func testRealNameSmsAndSubmitUseExpectedEndpoints() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "empty_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + + try await api.realNameSmsVerifyCode() + try await api.realNameSubmit( + RealNameAuthRequest( + realName: "测试摄影师", + idCardNo: "11010519491231002X", + smsVerifyCode: "123456", + startDate: "2026-01-01", + endDate: "2046-01-01", + isLongValid: 0, + frontUrl: "https://cdn.example.com/front.jpg", + backUrl: "https://cdn.example.com/back.jpg" + ) + ) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/real-name/sms-verify-code", + "/api/yf-handset-app/photog/real-name/submit" + ]) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests.last?.httpBody)) as? [String: Any]) + XCTAssertEqual(json["real_name"] as? String, "测试摄影师") + XCTAssertEqual(json["id_card_no"] as? String, "11010519491231002X") + XCTAssertEqual(json["sms_verify_code"] as? String, "123456") + XCTAssertEqual(json["is_long_valid"] as? Int, 0) + } + + /// 测试身份证号校验和格式归一化。 + func testRealNameIDCardValidation() { + XCTAssertTrue(RealNameAuthViewModel.isValidMainlandIDCardNumber("11010519491231002x")) + XCTAssertEqual(RealNameAuthViewModel.normalizedIDCardNumber(" 11010519491231002x "), "11010519491231002X") + XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("110105194912310021")) + XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123")) + } + + /// 测试头像变更时先上传 OSS,再回写头像 URL。 + func testProfileAvatarUploadUpdatesAvatarURL() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "empty_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + let uploader = MockOSSUploader() + let viewModel = ProfileViewModel() + viewModel.userInfo = UserInfoResponse(avatar: "", nickname: "旧昵称") + viewModel.beginEditing() + try viewModel.prepareAvatarImage(data: makeJPEGData(), timestamp: 1_767_225_600) + + try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10) + + XCTAssertEqual(uploader.userAvatarUploads.count, 1) + XCTAssertEqual(uploader.userAvatarUploads.first?.scenicId, 10) + XCTAssertEqual(viewModel.userInfo?.avatar, uploader.avatarURL) + XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update-avatar-url"]) + } + + /// 测试头像未变更时不会调用 OSS 上传。 + func testProfileNicknameOnlySaveDoesNotUploadAvatar() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "empty_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + let uploader = MockOSSUploader() + let viewModel = ProfileViewModel() + viewModel.userInfo = UserInfoResponse(avatar: "https://cdn.example.com/old.jpg", nickname: "旧昵称") + viewModel.beginEditing() + viewModel.editingNickname = "新昵称" + + try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10) + + XCTAssertTrue(uploader.userAvatarUploads.isEmpty) + XCTAssertEqual(viewModel.userInfo?.nickname, "新昵称") + XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update"]) + } + + /// 测试实名认证提交前会上传正反面图片并提交最终 URL。 + func testRealNameSubmitUploadsImagesBeforeSubmitting() async throws { + let session = ProfileSecondaryURLSession(responses: [ + try TestFixture.data(named: "empty_success"), + try TestFixture.data(named: "real_name_info_success") + ]) + let api = ProfileAPI(client: APIClient(session: session)) + let uploader = MockOSSUploader() + let viewModel = RealNameAuthViewModel() + viewModel.realName = "测试摄影师" + viewModel.idCardNo = "11010519491231002X" + viewModel.smsCode = "123456" + try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front, timestamp: 1_767_225_600) + try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back, timestamp: 1_767_225_601) + + try await viewModel.submit(api: api, uploader: uploader, scenicId: 10) + + XCTAssertEqual(uploader.realNameUploads.map(\.fileName), ["real_name_front_1767225600.jpg", "real_name_back_1767225601.jpg"]) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/real-name/submit", + "/api/yf-handset-app/photog/real-name/info" + ]) + let body = try XCTUnwrap(session.requests.first?.httpBody) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["front_url"] as? String, uploader.realNameURLs[0]) + XCTAssertEqual(json["back_url"] as? String, uploader.realNameURLs[1]) + } + + /// 测试实名认证缺少图片时阻止提交。 + func testRealNameSubmitRequiresImages() async throws { + let api = ProfileAPI(client: APIClient(session: ProfileSecondaryURLSession(responses: []))) + let uploader = MockOSSUploader() + let viewModel = RealNameAuthViewModel() + viewModel.realName = "测试摄影师" + viewModel.idCardNo = "11010519491231002X" + viewModel.smsCode = "123456" + + do { + try await viewModel.submit(api: api, uploader: uploader, scenicId: 10) + XCTFail("缺少证件图片时应阻止提交") + } catch { + XCTAssertEqual(error as? RealNameValidationError, .message("请选择身份证人像面图片")) + } + XCTAssertTrue(uploader.realNameUploads.isEmpty) + } + + /// 测试实名认证图片上传失败时不会提交资料。 + func testRealNameSubmitStopsWhenUploadFails() async throws { + let session = ProfileSecondaryURLSession(responses: []) + let api = ProfileAPI(client: APIClient(session: session)) + let uploader = MockOSSUploader(error: OSSUploadError.unsupportedFileType) + let viewModel = RealNameAuthViewModel() + viewModel.realName = "测试摄影师" + viewModel.idCardNo = "11010519491231002X" + viewModel.smsCode = "123456" + try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front) + try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back) + + do { + try await viewModel.submit(api: api, uploader: uploader, scenicId: 10) + XCTFail("上传失败时应阻止提交") + } catch { + XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType) + } + + XCTAssertTrue(session.requests.isEmpty) + XCTAssertFalse(viewModel.submitting) + } + + /// 测试首页系统设置 URI 已接入真实设置页。 + func testHomeSystemSettingsRouteResolvesToSettingsPage() { + XCTAssertEqual( + HomeMenuRouter.resolve(uri: "system_settings", title: "设置中心"), + .destination(.settings) + ) + } +} + +/// 个人中心测试用 URLSession 替身,按顺序返回预设响应。 +private final class ProfileSecondaryURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化顺序响应。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回下一份响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} + +/// 个人中心测试用 OSS 上传替身,记录上传请求并返回固定 URL。 +private final class MockOSSUploader: OSSUploadServing { + struct UploadRecord: Equatable { + let fileName: String + let scenicId: Int + } + + let avatarURL = "https://cdn.example.com/avatar-new.jpg" + let realNameURLs = [ + "https://cdn.example.com/front-new.jpg", + "https://cdn.example.com/back-new.jpg" + ] + private let error: Error? + private(set) var userAvatarUploads: [UploadRecord] = [] + private(set) var realNameUploads: [UploadRecord] = [] + + /// 初始化上传替身,可选指定要抛出的错误。 + init(error: Error? = nil) { + self.error = error + } + + /// 模拟上传用户头像。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let error { throw error } + userAvatarUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId)) + onProgress(100) + return avatarURL + } + + /// 模拟上传实名认证证件图片。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let error { throw error } + realNameUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId)) + onProgress(100) + return realNameURLs[min(realNameUploads.count - 1, realNameURLs.count - 1)] + } + + /// 模拟上传云盘文件。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传相册文件。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传任务附件。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传项目图片。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传打卡点图片。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传景区申请图片。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } + + /// 模拟上传银行卡照片。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + avatarURL + } +} + +/// 生成测试用 JPEG 图片数据。 +private func makeJPEGData() throws -> Data { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 12)) + let image = renderer.image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 12, height: 12)) + } + return try XCTUnwrap(image.jpegData(compressionQuality: 0.9)) +} diff --git a/suixinkan_iosTests/Projects/ProjectAPITests.swift b/suixinkan_iosTests/Projects/ProjectAPITests.swift new file mode 100644 index 0000000..9cfa4c8 --- /dev/null +++ b/suixinkan_iosTests/Projects/ProjectAPITests.swift @@ -0,0 +1,150 @@ +// +// ProjectAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 项目 API 测试,覆盖摄影师项目和店铺项目请求。 +final class ProjectAPITests: XCTestCase { + /// 测试摄影师项目列表接口 path 和 query。 + func testProjectListUsesExpectedPathAndQuery() async throws { + let session = ProjectRecordingURLSession(data: Self.projectListResponse) + let api = ProjectAPI(client: APIClient(session: session)) + + let payload = try await api.projectList(scenicId: 9, name: " 亲子 ", page: 0, pageSize: 0) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/project/list") + let query = projectQueryItems(from: request) + XCTAssertEqual(query["scenic_id"], "9") + XCTAssertEqual(query["name"], "亲子") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(payload.list.first?.id, 88) + } + + /// 测试项目详情和删除接口路径正确。 + func testProjectDetailAndDeleteUseExpectedPath() async throws { + let session = ProjectRecordingURLSession(responses: [Self.projectDetailResponse, Self.emptyResponse]) + let api = ProjectAPI(client: APIClient(session: session)) + + let detail = try await api.projectDetail(id: 88) + try await api.deleteProject(id: 88) + + XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/project/info-view") + XCTAssertEqual(projectQueryItems(from: session.requests[0])["id"], "88") + XCTAssertEqual(detail.materialNum, 12) + XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/project/delete") + XCTAssertEqual(try bodyObject(from: session.requests[1])["id"] as? Int, 88) + } + + /// 测试创建和编辑摄影师项目请求体。 + func testCreateAndEditProjectUseExpectedBody() async throws { + let session = ProjectRecordingURLSession(data: Self.emptyResponse) + let api = ProjectAPI(client: APIClient(session: session)) + let request = ProjectCreateRequest( + id: 7, + type: 11, + scenicId: 9, + name: "项目", + price: "99.00", + otPrice: "199.00", + coverProject: "https://cdn/p.jpg", + coverCarousel: ["https://cdn/a.jpg"], + description: "描述", + attrLabel: ["亲子"], + extra: ProjectCreateExtra(priceDeposit: "10.00", materialNum: 1, photoNum: 2, videoNum: 3, scenicSpotId: [6], photogUid: [100]) + ) + + try await api.createProject(request) + try await api.editProject(request) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/project/create", + "/api/yf-handset-app/photog/project/edit" + ]) + let body = try bodyObject(from: session.requests[0]) + XCTAssertEqual(body["scenic_id"] as? Int, 9) + XCTAssertEqual(body["cover_project"] as? String, "https://cdn/p.jpg") + XCTAssertEqual((body["extra"] as? [String: Any])?["photo_num"] as? Int, 2) + } + + /// 测试店铺项目接口 path、query 和 body。 + func testStoreManagerRequestsUseExpectedPathAndBody() async throws { + let session = ProjectRecordingURLSession(responses: [ + Self.scenicListResponse, + Self.projectListResponse, + Self.projectDetailResponse, + Self.emptyResponse, + Self.emptyResponse + ]) + let api = ProjectAPI(client: APIClient(session: session)) + + _ = try await api.storeManagerScenicList(userId: "100") + _ = try await api.storeManagerProjectList(userId: "100", page: 0, pageSize: 0) + _ = try await api.storeManagerProjectDetail(id: 88) + try await api.storeManagerCreate(StoreManagerCreateRequest(name: "店铺项目", storeId: nil, type: 19, description: "描述", coverProject: "u", coverCarousel: [], projectRule: nil, scenicId: [9], settleSpotNum: 1, price: 100, priceMaterial: 1, pricePhoto: 2, priceVideo: 3, priceMaterialAll: nil, packageList: nil, userId: 100, singleSpotMaterialNum: 1, singleSpotPhotoNum: 2, singleSpotVideoNum: 3)) + try await api.storeManagerOfflineCreate(StoreManagerOfflineCreateRequest(name: "押金", scenicId: 9, storeId: 3, description: "描述", price: 20, coverProject: "u", coverCarousel: [])) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/app/store-manager/scenic-list", + "/api/app/store-manager/list", + "/api/app/store-manager/detail", + "/api/app/store-manager/create", + "/api/app/store-manager/offline-create" + ]) + XCTAssertEqual(projectQueryItems(from: session.requests[0])["user_id"], "100") + XCTAssertEqual(projectQueryItems(from: session.requests[1])["page"], "1") + XCTAssertEqual((try bodyObject(from: session.requests[3]))["settle_spot_num"] as? Int, 1) + XCTAssertEqual((try bodyObject(from: session.requests[4]))["store_id"] as? Int, 3) + } + + private static let emptyResponse = Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8) + private static let projectListResponse = Data(#"{"code":100000,"message":"ok","data":{"total":"2","list":[{"id":"88","type":"11","type_name":"旅拍","status":"1","status_name":"运营中","name":"亲子旅拍","cover_project":"https://cdn/p.jpg","price":"99.00","ot_price":199,"price_deposit":"10","attr_label":"亲子,航拍"}]}}"#.utf8) + private static let projectDetailResponse = Data(#"{"code":100000,"message":"ok","data":{"id":"88","scenic_id":"9","cover_project":"https://cdn/p.jpg","cover_carousel":["https://cdn/a.jpg"],"name":"亲子旅拍","type":"11","type_name":"旅拍","status":"1","status_name":"运营中","price":"99.00","ot_price":"199.00","price_deposit":"10.00","description":"描述","attr_label":["亲子"],"sold":"3","material_num":"12","photo_num":"6","video_num":"1","created_at":"2026-06-24","creator_name":"摄影师","photog_list":[{"id":"1","photog_uid":"100","nickname":"小李","avatar":"","completed_order_count":"8"}],"scenic_list":[{"id":"6","name":"大门"}]}}"#.utf8) + private static let scenicListResponse = Data(#"{"code":100000,"message":"ok","data":{"total":1,"list":[{"id":"9","name":"测试景区"}]}}"#.utf8) +} + +/// 项目 API 测试 URLSession,记录请求并返回固定数据。 +private final class ProjectRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化单响应测试 Session。 + init(data: Data) { + self.responses = [data] + } + + /// 初始化多响应测试 Session。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.count > 1 ? responses.removeFirst() : responses[0] + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +/// 从请求中提取 query 字典。 +private func projectQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +/// 将请求体解析为字典。 +private func bodyObject(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} diff --git a/suixinkan_iosTests/Projects/ProjectViewModelTests.swift b/suixinkan_iosTests/Projects/ProjectViewModelTests.swift new file mode 100644 index 0000000..24ddbf3 --- /dev/null +++ b/suixinkan_iosTests/Projects/ProjectViewModelTests.swift @@ -0,0 +1,195 @@ +// +// ProjectViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 项目 ViewModel 测试,覆盖分页、校验和上传提交顺序。 +final class ProjectViewModelTests: XCTestCase { + /// 测试无景区时清空列表且不请求接口。 + func testProjectReloadWithoutScenicClearsListAndSkipsRequest() async { + let api = FakeProjectService() + let viewModel = ProjectManagementViewModel() + + await viewModel.reload(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertEqual(api.projectListCalls.count, 0) + } + + /// 测试项目列表首屏和加载更多分页。 + func testProjectListReloadAndLoadMore() async { + let api = FakeProjectService() + api.projectPages = [ + ListPayload(total: 2, list: [.fixture(id: 1, name: "A")]), + ListPayload(total: 2, list: [.fixture(id: 2, name: "B")]) + ] + let viewModel = ProjectManagementViewModel() + + await viewModel.reload(api: api, scenicId: 9) + await viewModel.loadMore(api: api, scenicId: 9) + + XCTAssertEqual(api.projectListCalls.map(\.page), [1, 2]) + XCTAssertEqual(viewModel.items.map(\.id).sorted(), [1, 2]) + XCTAssertFalse(viewModel.hasMore) + } + + /// 测试摄影师项目提交会先上传图片再调用创建接口。 + func testProjectEditorUploadsImagesBeforeCreate() async { + let api = FakeProjectService() + let uploader = FakeProjectUploader() + let viewModel = ProjectEditorViewModel(mode: .create) + viewModel.name = "新项目" + viewModel.descriptionText = "描述" + viewModel.price = "99" + viewModel.otPrice = "199" + viewModel.deposit = "10" + viewModel.selectedSpotIds = [6] + viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil) + viewModel.carouselImages = [ProjectLocalImage(data: Data([2]), fileName: "banner.jpg", previewURL: nil)] + + let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFileNames, ["cover.jpg", "banner.jpg"]) + XCTAssertEqual(api.createdProjects.first?.coverProject, "https://cdn/cover.jpg") + XCTAssertEqual(api.createdProjects.first?.coverCarousel, ["https://cdn/banner.jpg"]) + } + + /// 测试上传失败时不提交业务接口。 + func testProjectEditorUploadFailureDoesNotCreateProject() async { + let api = FakeProjectService() + let uploader = FakeProjectUploader() + uploader.shouldFail = true + let viewModel = ProjectEditorViewModel(mode: .create) + viewModel.name = "新项目" + viewModel.descriptionText = "描述" + viewModel.price = "99" + viewModel.selectedSpotIds = [6] + viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil) + + let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertTrue(api.createdProjects.isEmpty) + } + + /// 测试店铺项目缺用户 ID 时不提交。 + func testStoreProjectEditorRequiresUserId() async { + let viewModel = StoreProjectEditorViewModel(mode: .create) + let success = await viewModel.submit(userId: 0, api: FakeProjectService(), uploadService: FakeProjectUploader()) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.errorMessage, ProjectEditorError.missingUser.localizedDescription) + } +} + +private extension PhotographerProjectItem { + /// 创建测试项目列表项。 + static func fixture(id: Int, name: String) -> PhotographerProjectItem { + PhotographerProjectItem(id: id, name: name, price: "99.00") + } +} + +/// 项目测试服务替身。 +@MainActor +private final class FakeProjectService: ProjectServing { + var projectPages: [ListPayload] = [] + var projectListCalls: [(scenicId: Int, name: String?, page: Int, pageSize: Int)] = [] + var createdProjects: [ProjectCreateRequest] = [] + + /// 获取摄影师项目列表。 + func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload { + projectListCalls.append((scenicId, name, page, pageSize)) + return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst() + } + + /// 获取摄影师项目详情。 + func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { + try Self.detail() + } + + /// 创建摄影师项目。 + func createProject(_ request: ProjectCreateRequest) async throws { + createdProjects.append(request) + } + + /// 编辑摄影师项目。 + func editProject(_ request: ProjectCreateRequest) async throws {} + + /// 删除摄影师项目。 + func deleteProject(id: Int) async throws {} + + /// 获取店铺项目可管理景区。 + func storeManagerScenicList(userId: String) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 获取店铺项目列表。 + func storeManagerProjectList(userId: String?, page: Int, pageSize: Int) async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 获取店铺项目详情。 + func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { + try Self.detail() + } + + /// 创建店铺多点位项目。 + func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws {} + + /// 创建店铺押金项目。 + func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws {} + + /// 更新店铺多点位项目。 + func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws {} + + /// 更新店铺押金项目。 + func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws {} + + /// 删除店铺项目。 + func storeManagerDeleteProject(id: Int) async throws {} + + /// 获取全部门店。 + func storeAll() async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + private static func detail() throws -> PhotographerProjectDetailResponse { + let data = Data(#"{"id":1,"name":"项目","type":11,"status":1,"price":"99","description":"描述","material_num":1,"photo_num":1,"video_num":1}"#.utf8) + return try JSONDecoder().decode(PhotographerProjectDetailResponse.self, from: data) + } +} + +/// 项目上传测试替身。 +@MainActor +private final class FakeProjectUploader: OSSUploadServing { + var uploadedFileNames: [String] = [] + var shouldFail = false + + /// 上传项目图片。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if shouldFail { throw TestUploadError.failed } + uploadedFileNames.append(fileName) + return "https://cdn/\(fileName)" + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +/// 测试上传错误。 +private enum TestUploadError: Error { + case failed +} diff --git a/suixinkan_iosTests/PunchPoint/PunchPointAPITests.swift b/suixinkan_iosTests/PunchPoint/PunchPointAPITests.swift new file mode 100644 index 0000000..ab9dee2 --- /dev/null +++ b/suixinkan_iosTests/PunchPoint/PunchPointAPITests.swift @@ -0,0 +1,233 @@ +// +// PunchPointAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 打卡点 API 测试,覆盖旧工程接口路径、参数和宽松解码。 +final class PunchPointAPITests: XCTestCase { + /// 测试打卡点列表接口使用正确 path 和 query。 + func testPunchPointListUsesExpectedPathAndQuery() async throws { + let session = PunchPointRecordingSession(data: Self.listResponse) + let api = PunchPointAPI(client: APIClient(session: session)) + + let payload = try await api.punchPointList(scenicId: 88, status: -1, page: 0, pageSize: 0) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/scenic-spot/list") + let query = queryItems(from: request) + XCTAssertEqual(query["scenic_area_id"], "88") + XCTAssertEqual(query["status"], "0") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(payload.total, 1) + XCTAssertEqual(payload.list.first?.id, 7) + XCTAssertEqual(payload.list.first?.region?.lat, 30.1) + } + + /// 测试详情、新增、编辑、删除接口路径和请求体。 + func testPunchPointMutationRequestsUseExpectedBodies() async throws { + let session = PunchPointRecordingSession(responses: [Self.infoResponse, Self.emptyResponse, Self.emptyResponse, Self.emptyResponse]) + let api = PunchPointAPI(client: APIClient(session: session)) + let region = PunchPointRegion(lat: 30.1, lot: 120.2, address: "入口", scenicSpotStr: "入口点") + + _ = try await api.punchPointInfo(id: 7) + try await api.addPunchPoint(AddPunchPointRequest(scenicAreaId: "88", name: "入口", description: "描述", region: region, scenicSpotStr: "入口点", guideImages: ["https://cdn/a.jpg"])) + try await api.editPunchPoint(EditPunchPointRequest(id: 7, scenicAreaId: "88", name: "入口2", description: "描述2", region: region, scenicSpotStr: "入口点", guideImages: [])) + try await api.deletePunchPoint(id: 7) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/scenic-spot/info", + "/api/yf-handset-app/photog/scenic-spot/add", + "/api/yf-handset-app/photog/scenic-spot/edit", + "/api/yf-handset-app/photog/scenic-spot/delete" + ]) + XCTAssertEqual(queryItems(from: session.requests[0])["id"], "7") + let addBody = try bodyObject(from: session.requests[1]) + XCTAssertEqual(addBody["scenic_area_id"] as? String, "88") + XCTAssertEqual(addBody["name"] as? String, "入口") + XCTAssertEqual((addBody["guide_imgs"] as? [String])?.first, "https://cdn/a.jpg") + let deleteBody = try bodyObject(from: session.requests[3]) + XCTAssertEqual(deleteBody["id"] as? Int, 7) + } + + private static let emptyResponse = Data(#"{"code":100000,"msg":"ok","data":{}}"#.utf8) + private static let listResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"7","scenic_area_id":"88","name":100,"status":"1","status_label":"运营中","description":"描述","region":{"lat":"30.1","lot":120.2,"address":"入口","scenic_spot_str":"入口点"},"scenic_spot_str":"入口点","guide_imgs":["https://cdn/a.jpg"],"mp_qrcode":"https://cdn/qr.png","created_at":"2026-06-24"}]}}"#.utf8) + private static let infoResponse = Data(#"{"code":100000,"msg":"ok","data":{"id":7,"name":"入口","status":1,"region":{"lat":30.1,"lot":"120.2","address":"入口"},"guide_imgs":[]}}"#.utf8) +} + +@MainActor +/// 打卡点 ViewModel 测试,覆盖分页、删除和图片上传提交。 +final class PunchPointViewModelTests: XCTestCase { + /// 测试无景区时清空列表且不请求接口。 + func testListClearsWhenScenicMissing() async { + let api = MockPunchPointService() + let viewModel = PunchPointListViewModel() + viewModel.items = [PunchPointItem(id: 1, name: "旧数据")] + + await viewModel.reload(scenicId: nil, api: api) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertEqual(api.listRequests.count, 0) + } + + /// 测试列表分页和最后一页停止请求。 + func testListPaginationStopsAtLastPage() async { + let api = MockPunchPointService() + api.pages = [ + ListPayload(total: 2, list: [PunchPointItem(id: 1, name: "A")]), + ListPayload(total: 2, list: [PunchPointItem(id: 2, name: "B")]) + ] + let viewModel = PunchPointListViewModel() + + await viewModel.reload(scenicId: 88, api: api) + await viewModel.loadMore(scenicId: 88, api: api) + await viewModel.loadMore(scenicId: 88, api: api) + + XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) + XCTAssertEqual(api.listRequests.map(\.page), [1, 2]) + } + + /// 测试删除成功后刷新列表。 + func testDeleteRefreshesList() async { + let api = MockPunchPointService() + let viewModel = PunchPointListViewModel() + + let success = await viewModel.delete(PunchPointItem(id: 9, name: "A"), scenicId: 88, api: api) + + XCTAssertTrue(success) + XCTAssertEqual(api.deletedIds, [9]) + XCTAssertEqual(api.listRequests.count, 1) + } + + /// 测试新建打卡点会先上传本地图片,再提交最终 OSS URL。 + func testEditorUploadsImagesBeforeSubmit() async { + let api = MockPunchPointService() + let uploader = MockPunchPointUploader() + let viewModel = PunchPointEditorViewModel() + viewModel.name = "入口" + viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口") + viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")]) + + let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFileNames, ["a.jpg"]) + XCTAssertEqual(api.addRequests.first?.guideImages.first, "https://cdn.example.com/a.jpg") + } + + /// 测试上传失败时不提交打卡点接口。 + func testEditorUploadFailureDoesNotSubmit() async { + let api = MockPunchPointService() + let uploader = MockPunchPointUploader() + uploader.shouldFail = true + let viewModel = PunchPointEditorViewModel() + viewModel.name = "入口" + viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口") + viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")]) + + let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertEqual(api.addRequests.count, 0) + } +} + +/// 打卡点 API 测试用 URLSession。 +private final class PunchPointRecordingSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化测试 Session。 + init(data: Data) { + responses = [data] + } + + /// 初始化多个响应的测试 Session。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data(#"{"code":100000,"data":{}}"#.utf8) : responses.removeFirst() + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response) + } +} + +@MainActor +/// 打卡点服务测试替身。 +private final class MockPunchPointService: PunchPointServing { + var pages: [ListPayload] = [ListPayload(total: 0, list: [])] + var listRequests: [(scenicId: Int, status: Int, page: Int, pageSize: Int)] = [] + var addRequests: [AddPunchPointRequest] = [] + var editRequests: [EditPunchPointRequest] = [] + var deletedIds: [Int] = [] + + func punchPointList(scenicId: Int, status: Int, page: Int, pageSize: Int) async throws -> ListPayload { + listRequests.append((scenicId, status, page, pageSize)) + return pages.isEmpty ? ListPayload(total: 0, list: []) : pages.removeFirst() + } + + func punchPointInfo(id: Int) async throws -> PunchPointItem { + PunchPointItem(id: id, name: "详情") + } + + func addPunchPoint(_ request: AddPunchPointRequest) async throws { + addRequests.append(request) + } + + func editPunchPoint(_ request: EditPunchPointRequest) async throws { + editRequests.append(request) + } + + func deletePunchPoint(id: Int) async throws { + deletedIds.append(id) + } +} + +@MainActor +/// OSS 上传测试替身。 +private final class MockPunchPointUploader: OSSUploadServing { + var shouldFail = false + var uploadedFileNames: [String] = [] + + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if shouldFail { throw NSError(domain: "upload", code: 1) } + uploadedFileNames.append(fileName) + onProgress(100) + return "https://cdn.example.com/\(fileName)" + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +/// 从请求中提取 query 字典。 +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +/// 从请求体中解析 JSON 字典。 +private func bodyObject(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} diff --git a/suixinkan_iosTests/PushNotificationTests.swift b/suixinkan_iosTests/PushNotificationTests.swift new file mode 100644 index 0000000..1511ca6 --- /dev/null +++ b/suixinkan_iosTests/PushNotificationTests.swift @@ -0,0 +1,133 @@ +// +// PushNotificationTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/26. +// + +import Foundation +import XCTest +@testable import suixinkan_ios + +/// 推送链路测试,覆盖 APNs token、payload 解析、token 上报和通知路由。 +final class PushNotificationTests: XCTestCase { + func testAPNsDeviceTokenUsesLowercaseHexString() { + let token = APNsDeviceToken.hexString(from: Data([0x00, 0x0f, 0x10, 0xab, 0xff])) + + XCTAssertEqual(token, "000f10abff") + } + + func testPushPayloadRoutesTopLevelFields() { + XCTAssertEqual(PushPayload(userInfo: ["type": "1"]).route, .payment) + XCTAssertEqual(PushPayload(userInfo: ["route": "verification_order"]).route, .verificationOrder) + XCTAssertEqual(PushPayload(userInfo: ["uri": "/scenic-queue"]).route, .queue) + XCTAssertEqual(PushPayload(userInfo: ["action": "task_management"]).route, .task) + XCTAssertEqual(PushPayload(userInfo: ["route": "photographer_orders"]).route, .order) + } + + func testPushPayloadRoutesNestedJSONString() { + let payload = PushPayload(userInfo: [ + "extras": #"{"data":{"uri":"message_center","title":"系统消息"}}"# + ]) + + XCTAssertEqual(payload.route, .messageCenter) + } + + func testPushPayloadRoutesNestedDictionaryAndChineseKeywords() { + let payload = PushPayload(userInfo: [ + "data": [ + "extra": [ + "action": "叫号提醒" + ] + ] + ]) + + XCTAssertEqual(payload.route, .queue) + } + + func testPushPayloadFallsBackToMessageCenter() { + let payload = PushPayload(userInfo: ["title": "系统消息"]) + + XCTAssertEqual(payload.route, .messageCenter) + } + + func testRegisterPushTokenUsesCurrentBackendEndpointAndLegacyQueryName() async throws { + let session = PushRecordingURLSession() + let api = PushAPI(client: APIClient(session: session)) + + try await api.registerJPushId("000f10abff") + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/app/user/register-jpush-id") + XCTAssertEqual(queryItems(from: request)["jpush_reg_id"], "000f10abff") + } + + func testPushManagerRoutesHomeDestinationsThroughHomeNavigationStack() { + let router = AppRouter() + let manager = pushManager(router: router) + + manager.handleRemoteNotification(PushPayload(userInfo: ["route": "payment_qr"])) + XCTAssertEqual(router.selectedTab, .home) + XCTAssertEqual(router.router(for: .home).path, [.home(.paymentCollection)]) + + router.router(for: .home).reset() + manager.handleRemoteNotification(PushPayload(userInfo: ["route": "task_management"])) + XCTAssertEqual(router.router(for: .home).path, [.home(.taskManagement)]) + + router.router(for: .home).reset() + manager.handleRemoteNotification(PushPayload(userInfo: ["uri": "/scenic-queue"])) + XCTAssertEqual(router.router(for: .home).path, [.home(.queueManagement)]) + + router.router(for: .home).reset() + manager.handleRemoteNotification(PushPayload(userInfo: ["title": "未知通知"])) + XCTAssertEqual(router.router(for: .home).path, [.home(.messageCenter)]) + } + + func testPushManagerRoutesOrdersEntry() { + let router = AppRouter() + let manager = pushManager(router: router) + + manager.handleRemoteNotification(PushPayload(userInfo: ["route": "photographer_orders"])) + XCTAssertEqual(router.selectedTab, .orders) + XCTAssertEqual(router.selectedOrdersEntry, .storeOrders) + + manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"])) + XCTAssertEqual(router.selectedTab, .orders) + XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders) + } + + private func pushManager(router: AppRouter) -> PushNotificationManager { + let session = AppSession() + let manager = PushNotificationManager() + manager.configure( + api: PushAPI(client: APIClient(session: PushRecordingURLSession())), + session: session, + router: router + ) + return manager + } +} + +private final class PushRecordingURLSession: URLSessionProtocol { + private(set) var requests: [URLRequest] = [] + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)! + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response) + } +} + +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + guard let value = item.value else { return nil } + return (item.name, value) + }) +} diff --git a/suixinkan_iosTests/QueueManagement/QueueManagementViewModelTests.swift b/suixinkan_iosTests/QueueManagement/QueueManagementViewModelTests.swift new file mode 100644 index 0000000..536b606 --- /dev/null +++ b/suixinkan_iosTests/QueueManagement/QueueManagementViewModelTests.swift @@ -0,0 +1,227 @@ +// +// QueueManagementViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 排队管理列表测试,覆盖点位门禁、列表分页和动作。 +final class QueueManagementViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + clearQueueDefaults() + } + + func testReloadWithoutScenicClearsState() async { + let api = ScenicQueueMock() + let viewModel = QueueManagementViewModel() + + await viewModel.reload(api: api, scenicId: nil, userId: "77", spots: sampleSpots) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertNil(viewModel.selectedSpotId) + XCTAssertFalse(viewModel.queueGatePassed) + XCTAssertEqual(viewModel.queueCount, 0) + } + + func testReloadWithoutSavedSpotShowsGateAndDoesNotRequestQueue() async { + let api = ScenicQueueMock() + let viewModel = QueueManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots) + + XCTAssertEqual(viewModel.scenicSpots.map(\.id), [901, 902]) + XCTAssertNil(viewModel.selectedSpotId) + XCTAssertFalse(viewModel.queueGatePassed) + XCTAssertTrue(api.homeRequests.isEmpty) + XCTAssertTrue(api.statsRequests.isEmpty) + } + + func testReloadWithSavedSpotLoadsHomeAndStats() async { + saveSelectedSpot() + let api = ScenicQueueMock() + api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001), ticket(id: 1002)], total: 2), time: "2026-06-25 10:01")] + api.statsResponse = ScenicQueueStatsData(queueCount: 5, avgWaitMin: 12.5, time: "2026-06-25 10:02") + let viewModel = QueueManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots) + + XCTAssertEqual(viewModel.selectedSpotId, 901) + XCTAssertTrue(viewModel.queueGatePassed) + XCTAssertEqual(viewModel.items.map(\.id), [1001, 1002]) + XCTAssertEqual(viewModel.queueCount, 5) + XCTAssertEqual(viewModel.avgWaitMin, 12.5) + XCTAssertEqual(viewModel.lastSyncTimeText, "2026-06-25 10:02") + XCTAssertEqual(api.homeRequests.first?.type, QueueListType.queueing.rawValue) + } + + func testStatsFailureKeepsQueueList() async { + saveSelectedSpot() + let api = ScenicQueueMock() + api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1), time: "home-time")] + api.statsError = QueueTestError.sample + let viewModel = QueueManagementViewModel() + + await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots) + + XCTAssertEqual(viewModel.items.map(\.id), [1001]) + XCTAssertEqual(viewModel.queueCount, 0) + XCTAssertEqual(viewModel.lastSyncTimeText, "home-time") + } + + func testCallPassFinishRequeueAndUserMark() async throws { + saveSelectedSpot() + let api = ScenicQueueMock() + api.homeResponses = Array(repeating: ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1)), count: 8) + let viewModel = QueueManagementViewModel() + await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots) + + try await viewModel.callQueue(api: api, id: 1001) + XCTAssertEqual(api.callIds, [1001]) + XCTAssertEqual(viewModel.items.first?.isCalled, 1) + + try await viewModel.passQueue(api: api, scenicId: 88, userId: "77", id: 1001) + XCTAssertEqual(api.passIds, [1001]) + + try await viewModel.finishQueue(api: api, scenicId: 88, userId: "77", id: 1001) + XCTAssertEqual(api.finishIds, [1001]) + + try await viewModel.requeue(api: api, scenicId: 88, userId: "77", id: 1001, operatorId: 77) + XCTAssertEqual(api.requeueRequests.map(\.recordId), [1001]) + XCTAssertEqual(api.requeueRequests.map(\.operatorId), [77]) + XCTAssertEqual(viewModel.selectedListType, .queueing) + + try await viewModel.userMark( + api: api, + scenicId: 88, + userId: "77", + request: ScenicQueueUserMarkRequest(uid: 7001, scenicId: 88, markAsFreelancePhotog: 1, queueBanDays: 14, operatorId: 77) + ) + XCTAssertEqual(api.userMarkRequests.first?.uid, 7001) + XCTAssertEqual(api.userMarkRequests.first?.queueBanDays, 14) + } + + func testSocketSubscriptionAndParse() { + XCTAssertEqual(ScenicQueueSocketClient.subscriptionPayloads(scenicSpotId: 901), [ + #"{"type":306,"params":{"scenic_spot_id":901}}"#, + #"{"type":307,"params":{"scenic_spot_id":901}}"# + ]) + + let message = ScenicQueueSocketClient.parseMessage(""" + {"code":100000,"data":{"action":"scenic_queue_ticket_called","params":{"scenic_spot_id":"901","record_id":"1002","operator_uid":"77","event_id":"evt-1"}}} + """) + + XCTAssertEqual(message?.code, 100000) + XCTAssertTrue(message?.isScenicQueueEvent == true) + XCTAssertEqual(message?.data?.params?.scenicSpotId, 901) + XCTAssertEqual(message?.data?.params?.recordId, 1002) + XCTAssertEqual(message?.data?.params?.operatorUid, 77) + XCTAssertEqual(message?.data?.params?.eventId, "evt-1") + } +} + +private let sampleSpots = [ + ScenicSpotItem(id: 901, name: "东门打卡点"), + ScenicSpotItem(id: 902, name: "西门打卡点") +] + +private func saveSelectedSpot() { + ScenicQueueSettingsStore.saveSelectedSpot(id: 901, name: "东门打卡点", userId: "77", scenicId: 88) +} + +private func clearQueueDefaults() { + let defaults = UserDefaults.standard + [ + ScenicQueueLocalSettings.selectedSpotIdKey, + ScenicQueueLocalSettings.selectedSpotNameKey, + ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88), + ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88), + ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901) + ].forEach(defaults.removeObject) +} + +private func ticket(id: Int64, isCalled: Int = 0) -> ScenicQueueTicket { + ScenicQueueTicket( + id: id, + queueCode: "A\(id)", + mobile: "13800000000", + status: 0, + statusText: "", + waitMin: 5, + aheadCount: 1, + isCalled: isCalled, + createdAt: "2026-06-25 10:00:00", + calledAt: "", + expiredAt: "", + finishedAt: "", + queueCountToday: 2, + uid: 7001 + ) +} + +@MainActor +final class ScenicQueueMock: ScenicQueueServing { + var homeResponses: [ScenicQueueHomeData] = [] + var statsResponse = ScenicQueueStatsData() + var statsError: Error? + var settingResponse = ScenicQueueSettingData() + var callIds: [Int64] = [] + var passIds: [Int64] = [] + var finishIds: [Int64] = [] + var requeueRequests: [(recordId: Int64, operatorId: Int)] = [] + var userMarkRequests: [ScenicQueueUserMarkRequest] = [] + var saveSettingRequests: [ScenicQueueSaveSettingRequest] = [] + var homeRequests: [(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int)] = [] + var statsRequests: [(scenicId: Int, scenicSpotId: Int)] = [] + var qrcodeURL = "https://example.com/qrcode.png" + var logs = ScenicQueueSettingChangeLogData() + + func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData { + statsRequests.append((scenicId, scenicSpotId)) + if let statsError { throw statsError } + return statsResponse + } + + func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData { + homeRequests.append((scenicId, scenicSpotId, type, page, pageSize)) + return homeResponses.isEmpty ? ScenicQueueHomeData() : homeResponses.removeFirst() + } + + func socketToken() async throws -> SocketTokenResponse { SocketTokenResponse(socketToken: "token") } + func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData { + callIds.append(id) + return ScenicQueueCallData(id: id, status: 1, statusText: "已叫号") + } + func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData { + passIds.append(id) + return ScenicQueuePassData(id: id) + } + func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData { + finishIds.append(id) + return ScenicQueueFinishData(id: id) + } + func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws { + requeueRequests.append((recordId, operatorId)) + } + func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws { + userMarkRequests.append(request) + } + func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { settingResponse } + func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws { + saveSettingRequests.append(request) + } + func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData { + ScenicQueueShootQueueQRCodeData(qrcodeUrl: qrcodeURL) + } + func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData { + logs + } +} + +private enum QueueTestError: Error { + case sample +} diff --git a/suixinkan_iosTests/QueueManagement/ScenicQueueSettingsViewModelTests.swift b/suixinkan_iosTests/QueueManagement/ScenicQueueSettingsViewModelTests.swift new file mode 100644 index 0000000..37a1157 --- /dev/null +++ b/suixinkan_iosTests/QueueManagement/ScenicQueueSettingsViewModelTests.swift @@ -0,0 +1,182 @@ +// +// ScenicQueueSettingsViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 排队设置测试,覆盖加载、保存和校验。 +final class ScenicQueueSettingsViewModelTests: XCTestCase { + override func setUp() { + super.setUp() + clearQueueSettingsDefaults() + } + + func testLoadSelectsFirstSpotAndAppliesLocalSnapshotWhenServerMissing() async { + let snapshot = ScenicQueueSettingsSnapshot( + shootMinute: 4, + shootSecond: 15, + firstAheadCount: 6, + firstSms: false, + firstPhone: true, + secondAheadCount: 2, + secondSms: true, + secondPhone: false, + broadcastIntervalSec: 55, + countdownThresholdSec: 25, + queueDistanceMeter: 8_880, + queueTakeLimit: 11, + missCallRequeueOffset: 5, + showStartShootingButton: false, + autoCallAheadCount: 3, + quickCallButtonEnabled: true, + prepareCallButtonEnabled: true, + businessOpen: false, + businessStartTime: "09:30", + businessEndTime: "18:45" + ) + ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: "77", scenicId: 88, spotId: 901) + let api = ScenicQueueMock() + api.settingResponse = ScenicQueueSettingData(exists: false) + let viewModel = ScenicQueueSettingsViewModel() + + await viewModel.load(api: api, scenicId: 88, userId: "77", spots: settingsSpots) + + XCTAssertEqual(viewModel.selectedSpotId, 901) + XCTAssertEqual(viewModel.photoEstimateMin, "4") + XCTAssertEqual(viewModel.photoEstimateSec, "15") + XCTAssertEqual(viewModel.firstAhead, "6") + XCTAssertTrue(viewModel.firstPhone) + XCTAssertEqual(viewModel.secondAhead, "2") + XCTAssertEqual(viewModel.broadcastIntervalSec, "55") + XCTAssertEqual(viewModel.countdownThresholdSec, "25") + XCTAssertEqual(viewModel.maxQueueRangeKm, "8.88") + XCTAssertEqual(viewModel.localQueueLimit, "11") + XCTAssertEqual(viewModel.localPassDelay, "5") + XCTAssertFalse(viewModel.showStartShootingButton) + XCTAssertEqual(viewModel.autoCallAheadCount, "3") + XCTAssertTrue(viewModel.quickCallButtonEnabled) + XCTAssertTrue(viewModel.prepareCallButtonEnabled) + XCTAssertFalse(viewModel.queueOpen) + } + + func testSaveNormalizesBodyAndPersistsSnapshot() async throws { + let api = ScenicQueueMock() + let viewModel = ScenicQueueSettingsViewModel() + viewModel.scenicSpots = settingsSpots + viewModel.selectedSpotId = 901 + viewModel.photoEstimateMin = "1" + viewModel.photoEstimateSec = "20" + viewModel.firstAhead = "5" + viewModel.secondAhead = "2" + viewModel.broadcastIntervalSec = "45" + viewModel.countdownThresholdSec = "20" + viewModel.localQueueLimit = "7" + viewModel.localPassDelay = "3" + viewModel.showStartShootingButton = false + viewModel.autoCallAheadCount = "4" + viewModel.quickCallButtonEnabled = true + viewModel.prepareCallButtonEnabled = true + viewModel.queueOpen = false + viewModel.customTtsText = " {number} 请到拍摄点 " + viewModel.presetVoices = ["预设播报", " ", "第二条"] + viewModel.businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 11, minute: 0) + viewModel.businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 21, minute: 30) + viewModel.updateMaxQueueRange("12.3") + + try await viewModel.save(api: api, scenicId: 88, userId: "77") + + let request = try XCTUnwrap(api.saveSettingRequests.first) + XCTAssertEqual(request.scenicId, 88) + XCTAssertEqual(request.scenicSpotId, 901) + XCTAssertEqual(request.photoEstimateMin, 1) + XCTAssertEqual(request.photoEstimateSec, 20) + XCTAssertEqual(request.queueDistanceMeter, 12_300) + XCTAssertEqual(request.queueTakeLimit, 7) + XCTAssertEqual(request.missCallRequeueOffset, 3) + XCTAssertEqual(request.showStartShootButton, 0) + XCTAssertEqual(request.autoCallNextCount, 4) + XCTAssertEqual(request.businessStartTime, "11:00") + XCTAssertEqual(request.businessEndTime, "21:30") + XCTAssertEqual(request.voiceBroadcasts.map(\.content), ["预设播报", "第二条"]) + XCTAssertEqual(request.status, 0) + XCTAssertEqual(request.remark, "{number} 请到拍摄点") + + XCTAssertEqual(ScenicQueueSettingsStore.selectedSpotId(userId: "77", scenicId: 88), 901) + let snapshot = try XCTUnwrap(ScenicQueueSettingsStore.settingsSnapshot(userId: "77", scenicId: 88, spotId: 901)) + XCTAssertEqual(snapshot.queueDistanceMeter, 12_300) + XCTAssertEqual(snapshot.autoCallAheadCount, 4) + XCTAssertTrue(snapshot.quickCallButtonEnabled) + } + + func testValidationRejectsInvalidValues() async { + let api = ScenicQueueMock() + let viewModel = ScenicQueueSettingsViewModel() + viewModel.selectedSpotId = 901 + viewModel.photoEstimateMin = "0" + viewModel.photoEstimateSec = "0" + + await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77")) + + viewModel.photoEstimateSec = "20" + viewModel.firstAhead = "1" + viewModel.secondAhead = "2" + await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77")) + + viewModel.firstAhead = "2" + viewModel.secondAhead = "1" + viewModel.broadcastIntervalSec = "30" + await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77")) + } + + func testFetchQRCodeRejectsEmptyURL() async { + let api = ScenicQueueMock() + api.qrcodeURL = "" + let viewModel = ScenicQueueSettingsViewModel() + viewModel.selectedSpotId = 901 + + await XCTAssertThrowsErrorAsync(try await viewModel.fetchQRCode(api: api, scenicId: 88)) + } +} + +private let settingsSpots = [ + ScenicSpotItem(id: 901, name: "东门打卡点"), + ScenicSpotItem(id: 902, name: "西门打卡点") +] + +private func clearQueueSettingsDefaults() { + let defaults = UserDefaults.standard + [ + ScenicQueueLocalSettings.selectedSpotIdKey, + ScenicQueueLocalSettings.selectedSpotNameKey, + ScenicQueueLocalSettings.customTtsTextKey, + ScenicQueueLocalSettings.photoEstimateSecondsKey, + ScenicQueueLocalSettings.broadcastIntervalSecondsKey, + ScenicQueueLocalSettings.countdownThresholdSecondsKey, + ScenicQueueLocalSettings.showStartShootingButtonKey, + ScenicQueueLocalSettings.autoCallAheadCountKey, + ScenicQueueLocalSettings.quickCallButtonEnabledKey, + ScenicQueueLocalSettings.prepareCallButtonEnabledKey, + ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88), + ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88), + ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901), + ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: "77", scenicId: 88, spotId: 901), + ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: "77", scenicId: 88, spotId: 901) + ].forEach(defaults.removeObject) +} + +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + try await expression() + XCTFail("Expected error to be thrown", file: file, line: line) + } catch { + } +} diff --git a/suixinkan_iosTests/ScenicPermissionViewModelTests.swift b/suixinkan_iosTests/ScenicPermissionViewModelTests.swift new file mode 100644 index 0000000..ecb0166 --- /dev/null +++ b/suixinkan_iosTests/ScenicPermissionViewModelTests.swift @@ -0,0 +1,563 @@ +// +// ScenicPermissionViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import Foundation +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 景区权限申请测试,覆盖角色景区权限申请和新增景区申请流程。 +final class ScenicPermissionViewModelTests: XCTestCase { + /// 测试角色选项会按角色 ID 去重。 + func testPermissionApplyDeduplicatesRoles() { + let viewModel = PermissionApplyViewModel() + + viewModel.bootstrap(rolePermissions: [ + RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: []), + RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师重复"), scenic: []) + ]) + + XCTAssertEqual(viewModel.roleOptions.map(\.id), [1]) + XCTAssertEqual(viewModel.roleOptions.first?.name, "摄影师") + } + + /// 测试已拥有的景区会被禁用,不能作为新增申请提交。 + func testPermissionApplyDisablesExistingScenics() async { + let api = ScenicPermissionServingFake() + api.scenicList = ScenicListAllResponse(total: 2, list: [ + ScenicListItem(id: 1, name: "已有景区"), + ScenicListItem(id: 2, name: "新增景区") + ]) + let viewModel = PermissionApplyViewModel() + viewModel.bootstrap(rolePermissions: [ + RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [ScenicInfo(id: 1, name: "已有景区")]) + ]) + + viewModel.selectRole(id: 1) + await viewModel.loadScenicListIfNeeded(api: api) + viewModel.toggleScenic(id: 1) + viewModel.toggleScenic(id: 2) + + XCTAssertTrue(viewModel.scenicOptions.first { $0.id == 1 }?.disabled == true) + XCTAssertEqual(viewModel.selectedScenicIds, [2]) + XCTAssertTrue(viewModel.canSubmit) + } + + /// 测试切换角色后清空当前景区选择。 + func testPermissionApplyChangingRoleClearsSelection() async { + let api = ScenicPermissionServingFake() + api.scenicList = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 2, name: "新增景区")]) + let viewModel = PermissionApplyViewModel() + viewModel.bootstrap(rolePermissions: [ + RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: []), + RolePermissionResponse(role: RoleInfo(id: 2, name: "店长"), scenic: []) + ]) + viewModel.selectRole(id: 1) + await viewModel.loadScenicListIfNeeded(api: api) + viewModel.toggleScenic(id: 2) + + viewModel.selectRole(id: 2) + + XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) + XCTAssertTrue(viewModel.scenicOptions.isEmpty) + } + + /// 测试无角色或无景区时禁止提交。 + func testPermissionApplyRequiresRoleAndScenicBeforeSubmit() { + let viewModel = PermissionApplyViewModel() + + XCTAssertFalse(viewModel.canSubmit) + + viewModel.bootstrap(rolePermissions: [RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [])]) + viewModel.selectRole(id: 1) + + XCTAssertFalse(viewModel.canSubmit) + } + + /// 测试提交成功和失败会更新状态。 + func testPermissionApplySubmitSuccessAndFailure() async { + let api = ScenicPermissionServingFake() + let viewModel = PermissionApplyViewModel() + viewModel.bootstrap(rolePermissions: [RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [])]) + viewModel.selectRole(id: 1) + viewModel.scenicOptions = [PermissionScenicOption(id: 2, name: "新增景区", selected: false, disabled: false)] + viewModel.toggleScenic(id: 2) + + await viewModel.submit(api: api) + + XCTAssertEqual(api.submittedRoleId, 1) + XCTAssertEqual(api.submittedScenicIds, [2]) + XCTAssertEqual(viewModel.message, "提交成功,等待审核") + + api.roleApplySubmitError = TestError.sample + await viewModel.submit(api: api) + + XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription) + } + + /// 测试地区加载失败会进入初始化失败态。 + func testScenicApplicationAreaFailureMarksLoadFailed() async { + let api = ScenicPermissionServingFake() + api.areasError = TestError.sample + let viewModel = ScenicApplicationViewModel() + + await viewModel.loadInitial(api: api) + + XCTAssertTrue(viewModel.loadFailed) + XCTAssertTrue(viewModel.provinces.isEmpty) + } + + /// 测试待审核或驳回申请能回填表单。 + func testScenicApplicationPendingRefillsForm() async { + let api = ScenicPermissionServingFake() + api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])] + api.scenicPendings = ScenicApplicationPendingsResponse(items: [ + ScenicApplicationPendingResponse( + id: 1, + code: "A001", + scenicId: 9, + scenicName: "新景区", + scenicImages: "https://a.com/1.jpg,https://a.com/2.jpg", + scenicProvince: "浙江", + scenicCity: "杭州", + coopType: 3, + remark: "备注", + status: 3 + ) + ]) + let viewModel = ScenicApplicationViewModel() + + await viewModel.loadInitial(api: api) + + XCTAssertEqual(viewModel.scenicName, "新景区") + XCTAssertEqual(viewModel.remoteImageURLs.count, 2) + XCTAssertEqual(viewModel.selectedProvince, "浙江") + XCTAssertEqual(viewModel.selectedCity, "杭州") + XCTAssertEqual(viewModel.coopType, 3) + XCTAssertFalse(viewModel.isReadOnly) + } + + /// 测试景区申请必填项和协议校验。 + func testScenicApplicationValidation() { + let viewModel = ScenicApplicationViewModel() + + XCTAssertEqual(viewModel.validationMessage, "请输入景区名称") + + viewModel.scenicName = "新景区" + XCTAssertEqual(viewModel.validationMessage, "请至少上传一张景区图片") + + viewModel.imageURLs = "https://a.com/1.jpg" + XCTAssertEqual(viewModel.validationMessage, "请选择省份") + + viewModel.selectedProvince = "浙江" + XCTAssertEqual(viewModel.validationMessage, "请选择城市") + + viewModel.selectedCity = "杭州" + XCTAssertEqual(viewModel.validationMessage, "请先阅读并同意用户须知与隐私政策") + + viewModel.agreed = true + XCTAssertNil(viewModel.validationMessage) + } + + /// 测试本地图片会先上传 OSS,再提交最终 URL。 + func testScenicApplicationUploadsBeforeSubmit() async { + let api = ScenicPermissionServingFake() + let uploader = OSSUploadServingFake() + uploader.uploadedURLs = ["https://oss.com/scenic.jpg"] + let viewModel = ScenicApplicationViewModel() + viewModel.scenicName = "新景区" + viewModel.selectedProvince = "浙江" + viewModel.selectedCity = "杭州" + viewModel.agreed = true + viewModel.addLocalImage(data: Data([1, 2, 3]), fileName: "local.jpg") + + await viewModel.submit(api: api, uploader: uploader) + + XCTAssertEqual(uploader.uploadedFileNames, ["local.jpg"]) + XCTAssertEqual(api.submittedScenicRequest?.scenicImages, ["https://oss.com/scenic.jpg"]) + XCTAssertEqual(api.uploadPlaceholders.first?.fileName, "local.jpg") + XCTAssertEqual(viewModel.message, "提交成功") + } + + /// 测试图片上传失败时不提交表单。 + func testScenicApplicationUploadFailureDoesNotSubmit() async { + let api = ScenicPermissionServingFake() + let uploader = OSSUploadServingFake() + uploader.uploadError = TestError.sample + let viewModel = ScenicApplicationViewModel() + viewModel.scenicName = "新景区" + viewModel.selectedProvince = "浙江" + viewModel.selectedCity = "杭州" + viewModel.agreed = true + viewModel.addLocalImage(data: Data([1]), fileName: "local.jpg") + + await viewModel.submit(api: api, uploader: uploader) + + XCTAssertNil(api.submittedScenicRequest) + XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription) + } + + /// 测试驳回申请初始化时会回填角色和景区选择。 + func testPermissionApplyBootstrapWithRejectedPending() { + let pending = RoleApplyPendingResponse( + id: 9, + code: "AP-001", + roleId: 2, + roleName: "店长", + scenicList: [RoleApplyScenicItem(id: 3, name: "驳回景区")], + status: 3 + ) + let viewModel = PermissionApplyViewModel(initialPending: pending) + viewModel.bootstrap(rolePermissions: [ + RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师", notes: "拍摄"), scenic: []), + RolePermissionResponse(role: RoleInfo(id: 2, name: "店长", notes: "门店管理"), scenic: []) + ]) + + XCTAssertEqual(viewModel.selectedRoleId, 2) + XCTAssertEqual(viewModel.selectedScenicIds, [3]) + XCTAssertEqual(viewModel.selectedRoleName, "店长") + XCTAssertEqual(viewModel.selectedRoleNotes, "门店管理") + } + + /// 测试景区选择摘要会根据选择数量变化。 + func testPermissionApplySelectedScenicSummary() { + let viewModel = PermissionApplyViewModel() + viewModel.scenicOptions = [ + PermissionScenicOption(id: 1, name: "景区A", selected: false, disabled: false), + PermissionScenicOption(id: 2, name: "景区B", selected: false, disabled: false) + ] + viewModel.toggleScenic(id: 1) + viewModel.toggleScenic(id: 2) + + XCTAssertEqual(viewModel.selectedScenicSummary, "已选择 2 个景区") + + viewModel.toggleScenic(id: 2) + XCTAssertEqual(viewModel.selectedScenicSummary, "景区A") + } + + /// 测试已有角色权限列表和景区加载失败态。 + func testPermissionApplyExistingRolesAndScenicLoadFailure() async { + let api = ScenicPermissionServingFake() + api.scenicListError = TestError.sample + let viewModel = PermissionApplyViewModel() + viewModel.bootstrap(rolePermissions: [ + RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [ScenicInfo(id: 1, name: "已有景区")]) + ]) + viewModel.selectRole(id: 1) + + await viewModel.loadScenicListIfNeeded(api: api) + + XCTAssertEqual(viewModel.existingRoleInfos.first?.scenics.map(\.id), [1]) + XCTAssertTrue(viewModel.scenicLoadFailed) + XCTAssertEqual(viewModel.scenicLoadFailureReason, TestError.sample.localizedDescription) + XCTAssertEqual(viewModel.message, "景区列表加载失败,请重试") + XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) + } + + /// 测试权限申请状态页会按申请编号匹配记录。 + func testPermissionApplyStatusLoadByApplyCode() async { + let api = ScenicPermissionServingFake() + api.roleApplies = [ + RoleApplyPendingResponse(id: 1, code: "AP-001", roleId: 1, roleName: "摄影师", status: 2), + RoleApplyPendingResponse(id: 2, code: "AP-002", roleId: 2, roleName: "店长", status: 1) + ] + let viewModel = PermissionApplyStatusViewModel() + + await viewModel.load(api: api, applyCode: " AP-002 ") + + XCTAssertEqual(viewModel.pending?.code, "AP-002") + XCTAssertFalse(viewModel.loadFailed) + } + + /// 测试权限申请状态页无编号时会展示审核中或驳回记录。 + func testPermissionApplyStatusLoadPendingOrRejectedWithoutCode() async { + let api = ScenicPermissionServingFake() + api.roleApplies = [ + RoleApplyPendingResponse(id: 1, code: "AP-001", roleId: 1, roleName: "摄影师", status: 2), + RoleApplyPendingResponse(id: 2, code: "AP-002", roleId: 2, roleName: "店长", status: 3) + ] + let viewModel = PermissionApplyStatusViewModel() + + await viewModel.load(api: api, applyCode: nil) + + XCTAssertEqual(viewModel.pending?.status, 3) + } + + /// 测试权限申请状态页加载失败时会进入失败态。 + func testPermissionApplyStatusLoadFailure() async { + let api = ScenicPermissionServingFake() + api.roleApplyAllError = TestError.sample + let viewModel = PermissionApplyStatusViewModel() + + await viewModel.load(api: api, applyCode: "AP-001") + + XCTAssertNil(viewModel.pending) + XCTAssertTrue(viewModel.loadFailed) + XCTAssertEqual(viewModel.loadFailureReason, TestError.sample.localizedDescription) + } + + /// 测试审核中的景区申请会进入只读态。 + func testScenicApplicationReadOnlyWhenPendingReview() async { + let api = ScenicPermissionServingFake() + api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])] + api.scenicPendings = ScenicApplicationPendingsResponse(items: [ + ScenicApplicationPendingResponse( + id: 1, + code: "A001", + scenicId: 9, + scenicName: "审核中景区", + scenicImages: "https://a.com/1.jpg", + scenicProvince: "浙江", + scenicCity: "杭州", + coopType: 1, + remark: nil, + status: 1 + ) + ]) + let viewModel = ScenicApplicationViewModel() + + await viewModel.loadInitial(api: api) + + XCTAssertTrue(viewModel.isReadOnly) + XCTAssertTrue(viewModel.isSubmitLocked) + XCTAssertFalse(viewModel.canSubmit) + } + + /// 测试省份切换后会清空不匹配的城市。 + func testScenicApplicationOnProvinceChangeClearsInvalidCity() { + let viewModel = ScenicApplicationViewModel() + viewModel.provinces = [ + ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")]), + ScenicAreaNode(id: "js", name: "江苏", children: [ScenicAreaNode(id: "nj", name: "南京")]) + ] + viewModel.selectedProvince = "浙江" + viewModel.onProvinceChange() + viewModel.selectedCity = "杭州" + + viewModel.selectedProvince = "江苏" + viewModel.onProvinceChange() + + XCTAssertEqual(viewModel.cities.map(\.name), ["南京"]) + XCTAssertEqual(viewModel.selectedCity, "") + } + + /// 测试远程图片解析和删除逻辑。 + func testScenicApplicationRemoteImageParsingAndRemoval() { + let viewModel = ScenicApplicationViewModel() + viewModel.imageURLs = "https://a.com/1.jpg,https://a.com/2.jpg\nhttps://a.com/3.jpg" + + XCTAssertEqual(viewModel.remoteImageURLs, [ + "https://a.com/1.jpg", + "https://a.com/2.jpg", + "https://a.com/3.jpg" + ]) + XCTAssertEqual(viewModel.uploadPlaceholderCount, 3) + + viewModel.removeRemoteImage("https://a.com/2.jpg") + XCTAssertEqual(viewModel.remoteImageURLs, ["https://a.com/1.jpg", "https://a.com/3.jpg"]) + } + + /// 测试本地图片数量上限为 20 张。 + func testScenicApplicationMaxImageLimit() { + let viewModel = ScenicApplicationViewModel() + viewModel.imageURLs = Array(repeating: "https://a.com/x.jpg", count: 19).joined(separator: "\n") + + viewModel.addLocalImage(data: Data([1]), fileName: "local.jpg") + XCTAssertEqual(viewModel.uploadPlaceholderCount, 20) + + viewModel.addLocalImage(data: Data([2]), fileName: "overflow.jpg") + XCTAssertEqual(viewModel.uploadPlaceholderCount, 20) + XCTAssertEqual(viewModel.message, "最多上传20张景区图片") + } + + /// 测试上传占位策略会从 URL 提取文件名和类型。 + func testScenicUploadPlaceholderPolicy() { + XCTAssertEqual( + ScenicUploadPlaceholderPolicy.fileName(from: "https://cdn.example.com/scenic/a.jpg?x=1#preview", fallbackPrefix: "scenic"), + "a.jpg" + ) + XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "clip.mp4"), "video") + XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "doc.pdf"), "file") + XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "photo.heic"), "image") + } + + /// 测试仅远程图片时不会走 OSS 上传,但会提交占位和表单。 + func testScenicApplicationSubmitWithRemoteImagesOnly() async { + let api = ScenicPermissionServingFake() + let uploader = OSSUploadServingFake() + let viewModel = ScenicApplicationViewModel() + viewModel.scenicName = "新景区" + viewModel.imageURLs = "https://a.com/1.jpg" + viewModel.selectedProvince = "浙江" + viewModel.selectedCity = "杭州" + viewModel.agreed = true + + await viewModel.submit(api: api, uploader: uploader) + + XCTAssertTrue(uploader.uploadedFileNames.isEmpty) + XCTAssertEqual(api.submittedScenicRequest?.scenicImages, ["https://a.com/1.jpg"]) + XCTAssertEqual(api.uploadPlaceholders.count, 1) + XCTAssertEqual(viewModel.message, "提交成功") + } + + /// 测试没有匹配状态时清空待审核表单。 + func testScenicApplicationClearPendingWhenNoMatchingStatus() async { + let api = ScenicPermissionServingFake() + api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])] + api.scenicPendings = ScenicApplicationPendingsResponse(items: [ + ScenicApplicationPendingResponse( + id: 1, + code: "A001", + scenicId: 9, + scenicName: "已通过", + scenicImages: "", + scenicProvince: "浙江", + scenicCity: "杭州", + coopType: 1, + remark: nil, + status: 2 + ) + ]) + let viewModel = ScenicApplicationViewModel() + viewModel.scenicName = "草稿" + + await viewModel.loadInitial(api: api) + + XCTAssertNil(viewModel.pending) + XCTAssertEqual(viewModel.scenicName, "") + XCTAssertFalse(viewModel.agreed) + } +} + +@MainActor +/// 景区权限服务测试替身,用于验证 ViewModel 请求行为。 +private final class ScenicPermissionServingFake: ScenicPermissionServing { + var scenicList = ScenicListAllResponse(total: 0, list: []) + var scenicListError: Error? + var areas: [ScenicAreaNode] = [] + var areasError: Error? + var scenicPendings = ScenicApplicationPendingsResponse() + var scenicPendingsError: Error? + var submittedScenicRequest: ScenicApplicationSubmitRequest? + var uploadPlaceholders: [ScenicApplicationUploadPlaceholder] = [] + var roleApplies: [RoleApplyPendingResponse] = [] + var roleApplyAllError: Error? + var submittedRoleId: Int? + var submittedScenicIds: [Int] = [] + var roleApplySubmitError: Error? + + /// 返回景区列表或抛出测试错误。 + func scenicListAll() async throws -> ScenicListAllResponse { + if let scenicListError { throw scenicListError } + return scenicList + } + + /// 返回地区树或抛出测试错误。 + func areas() async throws -> [ScenicAreaNode] { + if let areasError { throw areasError } + return areas + } + + /// 返回景区申请记录或抛出测试错误。 + func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse { + if let scenicPendingsError { throw scenicPendingsError } + return scenicPendings + } + + /// 记录景区申请提交请求。 + func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws { + submittedScenicRequest = request + } + + /// 记录景区申请图片占位信息。 + func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws { + uploadPlaceholders = items + } + + /// 返回角色权限申请记录。 + func roleApplyAll() async throws -> [RoleApplyPendingResponse] { + if let roleApplyAllError { throw roleApplyAllError } + return roleApplies + } + + /// 记录角色权限申请提交请求。 + func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws { + if let roleApplySubmitError { throw roleApplySubmitError } + submittedRoleId = roleId + submittedScenicIds = scenicIds + } +} + +@MainActor +/// OSS 上传测试替身,用于验证景区申请图片上传顺序。 +private final class OSSUploadServingFake: OSSUploadServing { + var uploadedURLs: [String] = [] + var uploadedFileNames: [String] = [] + var uploadError: Error? + + /// 上传用户头像测试替身。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传实名认证图片测试替身。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传云盘文件测试替身。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传相册文件测试替身。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传任务文件测试替身。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传项目图片测试替身。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传打卡点图片测试替身。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传景区申请图片测试替身。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + + /// 上传银行卡图片测试替身。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) + } + + /// 统一记录上传请求并返回预设 URL。 + private func upload(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if let uploadError { throw uploadError } + uploadedFileNames.append(fileName) + onProgress(100) + return uploadedURLs.removeFirst() + } +} + +/// 测试错误实体。 +private enum TestError: LocalizedError { + case sample + + var errorDescription: String? { + "测试错误" + } +} diff --git a/suixinkan_iosTests/ScenicSelectionViewModelTests.swift b/suixinkan_iosTests/ScenicSelectionViewModelTests.swift new file mode 100644 index 0000000..7dd69fe --- /dev/null +++ b/suixinkan_iosTests/ScenicSelectionViewModelTests.swift @@ -0,0 +1,119 @@ +// +// ScenicSelectionViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 景区选择 ViewModel 测试,覆盖搜索、定位和切换持久化。 +final class ScenicSelectionViewModelTests: XCTestCase { + /// 测试当前景区会优先展示并标记选中。 + func testCurrentScenicMovesToFirstItem() { + let context = AccountContext() + context.replaceScopes( + scenic: [ + BusinessScope(id: 1, name: "西湖景区", kind: .scenic), + BusinessScope(id: 2, name: "东湖景区", kind: .scenic) + ], + stores: [], + currentScenicId: 2 + ) + let viewModel = ScenicSelectionViewModel() + + viewModel.reload(from: context) + + XCTAssertEqual(viewModel.items.map(\.id), [2, 1]) + XCTAssertTrue(viewModel.items[0].isClosest) + } + + /// 测试搜索会按景区名称和地址过滤。 + func testSearchFiltersByNameAndAddress() { + let viewModel = ScenicSelectionViewModel() + let context = AccountContext() + context.replaceScopes( + scenic: [ + BusinessScope(id: 1, name: "西湖景区", kind: .scenic, address: "杭州"), + BusinessScope(id: 2, name: "东湖景区", kind: .scenic, address: "武汉") + ], + stores: [] + ) + viewModel.reload(from: context) + + viewModel.searchQuery = "武汉" + + XCTAssertEqual(viewModel.filteredItems.map(\.id), [2]) + } + + /// 测试定位成功后会计算距离并标记最近景区。 + func testLocationSuccessMarksClosestScenic() { + let context = AccountContext() + context.replaceScopes( + scenic: [ + BusinessScope(id: 1, name: "远景区", kind: .scenic, address: "远处", latitude: 31.0, longitude: 121.0), + BusinessScope(id: 2, name: "近景区", kind: .scenic, address: "附近", latitude: 30.0001, longitude: 120.0001) + ], + stores: [] + ) + let viewModel = ScenicSelectionViewModel() + viewModel.reload(from: context) + + viewModel.applyCurrentLocation(latitude: 30.0, longitude: 120.0) + + XCTAssertEqual(viewModel.items.first(where: \.isClosest)?.id, 2) + XCTAssertEqual(viewModel.currentLocationText, "附近") + XCTAssertNotNil(viewModel.items.first(where: { $0.id == 2 })?.distanceMeters) + } + + /// 测试定位失败时保留列表并展示提示。 + func testLocationFailureKeepsItemsAndWarning() { + let context = AccountContext() + context.replaceScopes(scenic: [BusinessScope(id: 1, name: "西湖景区", kind: .scenic, address: "杭州")], stores: []) + let viewModel = ScenicSelectionViewModel() + viewModel.reload(from: context) + + viewModel.applyLocationFailure("定位权限未开启") + + XCTAssertEqual(viewModel.items.map(\.id), [1]) + XCTAssertEqual(viewModel.locationWarning, "定位权限未开启") + } + + /// 测试切换景区会联动门店并保存账号快照。 + func testSelectScenicUpdatesStoreAndPersistsSnapshot() { + let suiteName = "ScenicSelectionViewModelTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let snapshotStore = AccountSnapshotStore(defaults: defaults) + let accountContext = AccountContext() + let permissionContext = PermissionContext() + accountContext.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 + ) + permissionContext.replaceRolePermissions([RolePermissionResponse(role: RoleInfo(id: 7, name: "运营"), scenic: [])]) + + ScenicSelectionViewModel().select( + scenicId: 2, + accountContext: accountContext, + permissionContext: permissionContext, + snapshotStore: snapshotStore + ) + + XCTAssertEqual(accountContext.currentScenic?.id, 2) + XCTAssertEqual(accountContext.currentStore?.id, 20) + XCTAssertEqual(snapshotStore.load()?.currentScenicId, 2) + XCTAssertEqual(snapshotStore.load()?.currentStoreId, 20) + XCTAssertEqual(snapshotStore.load()?.currentRoleId, 7) + } +} diff --git a/suixinkan_iosTests/ScenicSettlement/ScenicSettlementViewModelTests.swift b/suixinkan_iosTests/ScenicSettlement/ScenicSettlementViewModelTests.swift new file mode 100644 index 0000000..2a4a588 --- /dev/null +++ b/suixinkan_iosTests/ScenicSettlement/ScenicSettlementViewModelTests.swift @@ -0,0 +1,212 @@ +// +// ScenicSettlementViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 景区结算测试,覆盖结算申请和审核记录聚合。 +final class ScenicSettlementViewModelTests: XCTestCase { + /// 测试已有景区会从可申请结算景区中排除。 + func testSettlementLoadExcludesExistingScenics() async { + let permissionAPI = ScenicSettlementPermissionMock() + permissionAPI.scenicList = ScenicListAllResponse(total: 3, list: [ + ScenicListItem(id: 1, name: "已有景区"), + ScenicListItem(id: 2, name: "新增景区"), + ScenicListItem(id: 3, name: "另一个景区") + ]) + let viewModel = ScenicSettlementViewModel() + + await viewModel.load( + api: permissionAPI, + existingScenics: [BusinessScope(id: 1, name: "已有景区", kind: .scenic)] + ) + + XCTAssertEqual(viewModel.options.map(\.id), [2, 3]) + } + + /// 测试金额校验覆盖空值、0、负数、超过两位小数和有效金额。 + func testSettlementAmountValidation() { + let viewModel = ScenicSettlementViewModel() + + XCTAssertEqual(viewModel.amountValidation, .empty) + + viewModel.amountText = "0" + XCTAssertEqual(viewModel.amountValidation, .notPositive) + + viewModel.amountText = "-1" + XCTAssertEqual(viewModel.amountValidation, .invalidFormat) + + viewModel.amountText = "1.234" + XCTAssertEqual(viewModel.amountValidation, .invalidFormat) + + viewModel.amountText = "12.30" + XCTAssertEqual(viewModel.amountValidation, .valid) + } + + /// 测试多景区提交 body 正确且成功后清空表单。 + func testSettlementSubmitMultipleScenicsAndClearsForm() async { + let submitAPI = ScenicSettlementSubmitMock() + let viewModel = ScenicSettlementViewModel() + viewModel.options = [ + ScenicSettlementOption(id: 3, name: "三号", selected: false), + ScenicSettlementOption(id: 2, name: "二号", selected: false) + ] + viewModel.toggleScenic(id: 3) + viewModel.toggleScenic(id: 2) + viewModel.amountText = "99" + viewModel.remarkText = " 活动结算 " + + let success = await viewModel.submit(api: submitAPI) + + XCTAssertTrue(success) + XCTAssertEqual(submitAPI.requests, [ + ScenicSettlementSubmitRequest(scenicId: 2, applyAmount: "99.00", applyRemark: "活动结算"), + ScenicSettlementSubmitRequest(scenicId: 3, applyAmount: "99.00", applyRemark: "活动结算") + ]) + XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) + XCTAssertEqual(viewModel.amountText, "") + XCTAssertEqual(viewModel.remarkText, "") + XCTAssertEqual(viewModel.message, "提交成功,等待审核") + } + + /// 测试任一提交失败会保留当前表单并透出错误。 + func testSettlementSubmitFailureKeepsForm() async { + let submitAPI = ScenicSettlementSubmitMock() + submitAPI.errorAfterRequestCount = 1 + let viewModel = ScenicSettlementViewModel() + viewModel.options = [ + ScenicSettlementOption(id: 1, name: "一号", selected: false), + ScenicSettlementOption(id: 2, name: "二号", selected: false) + ] + viewModel.toggleScenic(id: 1) + viewModel.toggleScenic(id: 2) + viewModel.amountText = "10" + + let success = await viewModel.submit(api: submitAPI) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.selectedScenicIds, [1, 2]) + XCTAssertEqual(viewModel.amountText, "10") + XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription) + } + + /// 测试审核记录两个通道都成功时汇总正确。 + func testReviewLoadSuccessAggregatesRecords() async { + let permissionAPI = ScenicSettlementPermissionMock() + permissionAPI.scenicPendings = ScenicApplicationPendingsResponse(items: [ + ScenicApplicationPendingResponse(id: 1, scenicName: "景区", status: 1) + ]) + permissionAPI.roleApplies = [ + RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 2) + ] + let viewModel = ScenicSettlementReviewViewModel() + + await viewModel.load(api: permissionAPI) + + XCTAssertEqual(viewModel.scenicApplications.count, 1) + XCTAssertEqual(viewModel.roleApplications.count, 1) + XCTAssertEqual(viewModel.pendingCount, 1) + XCTAssertFalse(viewModel.loadFailedAll) + } + + /// 测试单通道失败时保留另一通道数据。 + func testReviewSingleChannelFailureKeepsOtherChannel() async { + let permissionAPI = ScenicSettlementPermissionMock() + permissionAPI.scenicPendingsError = TestError.sample + permissionAPI.roleApplies = [ + RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 1) + ] + let viewModel = ScenicSettlementReviewViewModel() + + await viewModel.load(api: permissionAPI) + + XCTAssertTrue(viewModel.scenicLoadFailed) + XCTAssertFalse(viewModel.roleLoadFailed) + XCTAssertFalse(viewModel.loadFailedAll) + XCTAssertEqual(viewModel.roleApplications.count, 1) + } + + /// 测试双通道失败时进入整页失败态。 + func testReviewBothChannelsFailureMarksFullFailure() async { + let permissionAPI = ScenicSettlementPermissionMock() + permissionAPI.scenicPendingsError = TestError.sample + permissionAPI.roleAppliesError = TestError.sample + let viewModel = ScenicSettlementReviewViewModel() + + await viewModel.load(api: permissionAPI) + + XCTAssertTrue(viewModel.loadFailedAll) + XCTAssertTrue(viewModel.scenicApplications.isEmpty) + XCTAssertTrue(viewModel.roleApplications.isEmpty) + } + + /// 测试审核状态文案和语义映射。 + func testReviewStatusMapping() { + let viewModel = ScenicSettlementReviewViewModel() + + XCTAssertEqual(viewModel.statusText(1), "待审核") + XCTAssertEqual(viewModel.statusText(2), "已通过") + XCTAssertEqual(viewModel.statusText(3), "已驳回") + XCTAssertEqual(viewModel.statusText(9), "已取消") + XCTAssertEqual(viewModel.statusText(99), "未知") + XCTAssertEqual(viewModel.statusIcon(2), "checkmark.circle.fill") + } +} + +@MainActor +private final class ScenicSettlementPermissionMock: ScenicPermissionServing { + var scenicList = ScenicListAllResponse(total: 0, list: []) + var scenicListError: Error? + var scenicPendings = ScenicApplicationPendingsResponse() + var scenicPendingsError: Error? + var roleApplies: [RoleApplyPendingResponse] = [] + var roleAppliesError: Error? + + func scenicListAll() async throws -> ScenicListAllResponse { + if let scenicListError { throw scenicListError } + return scenicList + } + + func areas() async throws -> [ScenicAreaNode] { [] } + + func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse { + if let scenicPendingsError { throw scenicPendingsError } + return scenicPendings + } + + func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws {} + func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws {} + + func roleApplyAll() async throws -> [RoleApplyPendingResponse] { + if let roleAppliesError { throw roleAppliesError } + return roleApplies + } + + func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws {} +} + +@MainActor +private final class ScenicSettlementSubmitMock: ScenicSettlementServing { + var requests: [ScenicSettlementSubmitRequest] = [] + var errorAfterRequestCount: Int? + + func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws { + requests.append(request) + if let errorAfterRequestCount, requests.count >= errorAfterRequestCount { + throw TestError.sample + } + } +} + +private enum TestError: LocalizedError { + case sample + + var errorDescription: String? { + "测试错误" + } +} diff --git a/suixinkan_iosTests/ScenicSpotContextTests.swift b/suixinkan_iosTests/ScenicSpotContextTests.swift new file mode 100644 index 0000000..ce9bf33 --- /dev/null +++ b/suixinkan_iosTests/ScenicSpotContextTests.swift @@ -0,0 +1,76 @@ +// +// ScenicSpotContextTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 景点上下文测试,覆盖按景区懒加载和失败隔离。 +final class ScenicSpotContextTests: XCTestCase { + /// 测试景点列表会按当前景区 ID 请求并写入上下文。 + func testReloadRequestsSpotsForScenicId() async { + let context = ScenicSpotContext() + let api = MockScenicSpotAPI() + api.spotResponse = ListPayload(total: 1, list: [ScenicSpotItem(id: 9, name: "观景台")]) + + await context.reload(scenicId: 88, api: api) + + XCTAssertEqual(api.requestedScenicIds, [88]) + XCTAssertEqual(context.scenicId, 88) + XCTAssertEqual(context.spots.map(\.id), [9]) + XCTAssertEqual(context.loadState, .loaded) + } + + /// 测试景点接口失败时只标记景点模块失败。 + func testReloadFailureOnlyAffectsScenicSpotContext() async { + let context = ScenicSpotContext() + let api = MockScenicSpotAPI() + api.error = APIError.networkFailed("景点接口失败") + + await context.reload(scenicId: 88, api: api) + + XCTAssertEqual(api.requestedScenicIds, [88]) + XCTAssertTrue(context.spots.isEmpty) + if case .failed = context.loadState { + XCTAssertTrue(true) + } else { + XCTFail("景点接口失败时应进入 failed 状态") + } + } +} + +@MainActor +/// 景点服务测试替身,记录请求过的景区 ID。 +private final class MockScenicSpotAPI: AccountContextServing { + var requestedScenicIds: [Int] = [] + var spotResponse = ListPayload(total: 0, list: [ScenicSpotItem]()) + var error: Error? + + /// 获取空角色权限,当前测试不使用。 + func rolePermissions() async throws -> [RolePermissionResponse] { + [] + } + + /// 获取空景区列表,当前测试不使用。 + func scenicListAll() async throws -> ScenicListAllResponse { + ScenicListAllResponse(total: 0, list: []) + } + + /// 获取空门店列表,当前测试不使用。 + func storeAll() async throws -> ListPayload { + ListPayload(total: 0, list: []) + } + + /// 获取测试景点列表,并记录景区 ID。 + func scenicSpotListAll(scenicId: Int) async throws -> ListPayload { + requestedScenicIds.append(scenicId) + if let error { + throw error + } + return spotResponse + } +} diff --git a/suixinkan_iosTests/Schedule/ScheduleTests.swift b/suixinkan_iosTests/Schedule/ScheduleTests.swift new file mode 100644 index 0000000..3e5458f --- /dev/null +++ b/suixinkan_iosTests/Schedule/ScheduleTests.swift @@ -0,0 +1,184 @@ +// +// ScheduleTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 排班 API 和 ViewModel 测试。 +final class ScheduleTests: XCTestCase { + /// 测试排班 API path 和 query/body。 + func testScheduleAPIUsesExpectedRequests() async throws { + let session = ScheduleRecordingURLSession(responses: [ + Data(#"{"code":100000,"message":"ok","data":["2026-06-24"]}"#.utf8), + Data(#"{"code":100000,"message":"ok","data":[{"id":"1","name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}]}"#.utf8), + Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8), + Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8) + ]) + let api = ScheduleAPI(client: APIClient(session: session)) + + let days = try await api.monthScheduleDays(scenicId: 9, yearMonth: "2026-06") + let list = try await api.dayScheduleList(scenicId: 9, date: "2026-06-24") + try await api.addSchedule(AddScheduleRequest(scenicId: "9", name: "拍摄", remark: "备注", startTime: "09:00", endTime: "10:00", scheduleDate: "2026-06-24", orderNumber: "ORD001")) + try await api.deleteSchedule(id: 1) + + XCTAssertEqual(days, ["2026-06-24"]) + XCTAssertEqual(list.first?.id, 1) + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/schedule/list-date", + "/api/yf-handset-app/photog/schedule/list", + "/api/yf-handset-app/photog/schedule/add", + "/api/yf-handset-app/photog/schedule/delete" + ]) + XCTAssertEqual(scheduleQueryItems(from: session.requests[0])["date"], "2026-06") + XCTAssertEqual(scheduleQueryItems(from: session.requests[1])["date"], "2026-06-24") + XCTAssertEqual((try scheduleBodyObject(from: session.requests[2]))["order_number"] as? String, "ORD001") + XCTAssertEqual((try scheduleBodyObject(from: session.requests[3]))["id"] as? Int, 1) + } + + /// 测试无景区时排班管理清空状态且不请求接口。 + func testScheduleManagementWithoutScenicClearsState() async { + let api = FakeScheduleService() + let viewModel = ScheduleManagementViewModel() + + await viewModel.reload(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertTrue(viewModel.markedDays.isEmpty) + XCTAssertEqual(api.monthCalls.count, 0) + } + + /// 测试排班管理加载月份和当天列表。 + func testScheduleManagementReloadLoadsMonthAndDay() async { + let api = FakeScheduleService() + api.monthDays = ["2026-06-24"] + api.dayItems = [.fixture(id: 1)] + let viewModel = ScheduleManagementViewModel() + + await viewModel.reload(api: api, scenicId: 9) + + XCTAssertEqual(api.monthCalls.first?.scenicId, 9) + XCTAssertEqual(api.dayCalls.first?.scenicId, 9) + XCTAssertEqual(viewModel.markedDays, ["2026-06-24"]) + XCTAssertEqual(viewModel.items.first?.id, 1) + } + + /// 测试新增排班优先使用选择的订单号。 + func testScheduleAddUsesSelectedOrderBeforeManualOrder() async { + let api = FakeScheduleService() + let viewModel = ScheduleAddViewModel() + viewModel.draft.name = "拍摄" + viewModel.draft.remark = "" + viewModel.draft.manualOrderNumber = "MANUAL" + viewModel.draft.selectedOrder = AvailableOrderResponse.fixture(orderNumber: "PICKED") + let start = fixedDate(hour: 9) + let end = fixedDate(hour: 10) + + let success = await viewModel.submit(api: api, scenicId: 9, startDate: start, endDate: end) + + XCTAssertTrue(success) + XCTAssertEqual(api.addedRequests.first?.orderNumber, "PICKED") + XCTAssertEqual(api.addedRequests.first?.remark, "拍摄") + } + + /// 测试结束时间早于开始时间时禁止提交。 + func testScheduleAddRejectsInvalidTime() async { + let api = FakeScheduleService() + let viewModel = ScheduleAddViewModel() + viewModel.draft.name = "拍摄" + + let success = await viewModel.submit(api: api, scenicId: 9, startDate: fixedDate(hour: 10), endDate: fixedDate(hour: 9)) + + XCTAssertFalse(success) + XCTAssertTrue(api.addedRequests.isEmpty) + XCTAssertEqual(viewModel.errorMessage, ScheduleValidationError.invalidTime.localizedDescription) + } + + private func fixedDate(hour: Int) -> Date { + Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: hour, minute: 0)) ?? Date() + } +} + +private extension ScheduleItem { + /// 创建排班测试项。 + static func fixture(id: Int) -> ScheduleItem { + let data = Data(#"{"id":\#(id),"name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}"#.utf8) + return try! JSONDecoder().decode(ScheduleItem.self, from: data) + } +} + +private extension AvailableOrderResponse { + /// 创建可关联订单测试项。 + static func fixture(orderNumber: String) -> AvailableOrderResponse { + let data = Data(#"{"project_name":"项目","order_number":"\#(orderNumber)","order_status":"1","order_status_label":"已支付","pay_time":"2026-06-24","user_phone":"13800000000"}"#.utf8) + return try! JSONDecoder().decode(AvailableOrderResponse.self, from: data) + } +} + +/// 排班 API 测试 URLSession。 +private final class ScheduleRecordingURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化测试 Session。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.removeFirst() + return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} + +/// 排班服务测试替身。 +@MainActor +private final class FakeScheduleService: ScheduleServing { + var monthDays: [String] = [] + var dayItems: [ScheduleItem] = [] + var monthCalls: [(scenicId: Int, yearMonth: String)] = [] + var dayCalls: [(scenicId: Int, date: String)] = [] + var addedRequests: [AddScheduleRequest] = [] + + func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] { + monthCalls.append((scenicId, yearMonth)) + return monthDays + } + + func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] { + dayCalls.append((scenicId, date)) + return dayItems + } + + func addSchedule(_ request: AddScheduleRequest) async throws { + addedRequests.append(request) + } + + func deleteSchedule(id: Int) async throws {} + + func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] { + [] + } +} + +/// 从请求中提取 query 字典。 +private func scheduleQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +/// 将请求体解析为字典。 +private func scheduleBodyObject(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} diff --git a/suixinkan_iosTests/StatisticsViewModelTests.swift b/suixinkan_iosTests/StatisticsViewModelTests.swift new file mode 100644 index 0000000..d2ba87d --- /dev/null +++ b/suixinkan_iosTests/StatisticsViewModelTests.swift @@ -0,0 +1,238 @@ +// +// StatisticsViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 数据统计 ViewModel 测试,覆盖汇总、日数据、分页和角色接口选择。 +final class StatisticsViewModelTests: XCTestCase { + /// 测试首屏会加载汇总和第一页日数据。 + func testReloadLoadsSummaryAndDailyItems() async throws { + let api = MockStatisticsService() + api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")] + api.dailyResponses = [try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3")] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + + XCTAssertEqual(viewModel.summary.orderCount, 18) + XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"]) + XCTAssertEqual(viewModel.totalDailyCount, 3) + XCTAssertTrue(viewModel.hasMore) + XCTAssertFalse(viewModel.loading) + XCTAssertNotNil(viewModel.lastRefreshAt) + XCTAssertEqual(api.summaryCalls.map(\.range), ["1"]) + XCTAssertEqual(api.dailyCalls.map(\.page), [1]) + } + + /// 测试切换时间段会重置分页并使用对应 range。 + func testSelectPeriodResetsPaginationAndUsesExpectedRange() async throws { + let api = MockStatisticsService() + api.summaryResponses = [ + try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success"), + try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success") + ] + api.dailyResponses = [ + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3"), + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page2_total3"), + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3") + ] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) + XCTAssertEqual(viewModel.dailyItems.count, 3) + + try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, roleId: nil) + + XCTAssertEqual(viewModel.selectedPeriod, .sevenDays) + XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"]) + XCTAssertEqual(viewModel.totalDailyCount, 3) + XCTAssertTrue(viewModel.hasMore) + XCTAssertEqual(api.summaryCalls.last?.range, "3") + XCTAssertEqual(api.dailyCalls.last?.page, 1) + XCTAssertFalse(api.dailyCalls.last?.startTime.isEmpty ?? true) + XCTAssertFalse(api.dailyCalls.last?.endTime.isEmpty ?? true) + } + + /// 测试加载更多会追加日数据。 + func testLoadMoreAppendsDailyItems() async throws { + let api = MockStatisticsService() + api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")] + api.dailyResponses = [ + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3"), + try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page2_total3") + ] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) + + XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23", "2026-05-22", "2026-05-21"]) + XCTAssertFalse(viewModel.hasMore) + XCTAssertFalse(viewModel.loadingMore) + XCTAssertEqual(api.dailyCalls.map(\.page), [1, 2]) + } + + /// 测试加载更多失败时保留已有数据和下一页能力。 + func testLoadMoreFailureKeepsCurrentItems() async throws { + let api = MockStatisticsService() + api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")] + api.dailyResponses = [try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3")] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + api.nextDailyError = APIError.httpStatus(500, "server error") + + do { + try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) + XCTFail("Expected load more to fail") + } catch APIError.httpStatus(let statusCode, _) { + XCTAssertEqual(statusCode, 500) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"]) + XCTAssertTrue(viewModel.hasMore) + XCTAssertFalse(viewModel.loadingMore) + } + + /// 测试无景区时清空旧统计状态。 + func testReloadWithoutScenicClearsStaleStatisticsState() async throws { + let api = MockStatisticsService() + api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")] + api.dailyResponses = [try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3")] + let viewModel = StatisticsViewModel() + + try await viewModel.reload(api: api, scenicId: 88, roleId: nil) + XCTAssertEqual(viewModel.summary.orderCount, 18) + + try await viewModel.reload(api: api, scenicId: nil, roleId: nil) + + XCTAssertEqual(api.summaryCalls.count, 1) + XCTAssertEqual(viewModel.summary.orderCount, 0) + XCTAssertTrue(viewModel.dailyItems.isEmpty) + XCTAssertEqual(viewModel.totalDailyCount, 0) + XCTAssertFalse(viewModel.hasMore) + XCTAssertNil(viewModel.lastRefreshAt) + } + + /// 测试景区管理员角色使用管理员统计接口。 + func testStatisticsAPIUsesAdminEndpointForScenicAdminRole() async throws { + let session = SequencedURLSession(responses: [ + try TestFixture.data(named: "statistics_summary_success"), + try TestFixture.data(named: "statistics_daily_page1_total3") + ]) + let api = StatisticsAPI(client: APIClient(session: session)) + + _ = try await api.summary(scenicId: 88, range: "1", isScenicAdmin: true) + _ = try await api.dailyList( + scenicId: 88, + startTime: "2026-05-01", + endTime: "2026-05-31", + page: 2, + pageSize: 15, + isScenicAdmin: true + ) + + XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/scenic-admin/analyse", "/api/app/scenic-admin/analyse/daily"]) + let dailyQuery = queryItems(from: try XCTUnwrap(session.requests.last)) + XCTAssertEqual(dailyQuery["scenic_id"], "88") + XCTAssertEqual(dailyQuery["page"], "2") + XCTAssertEqual(dailyQuery["start_time"], "2026-05-01") + XCTAssertEqual(dailyQuery["end_time"], "2026-05-31") + } +} + +@MainActor +/// 数据统计服务测试替身,记录请求参数并返回预设响应。 +private final class MockStatisticsService: StatisticsServing { + struct SummaryCall: Equatable { + let scenicId: Int + let range: String + let isScenicAdmin: Bool + } + + struct DailyCall: Equatable { + let scenicId: Int + let startTime: String + let endTime: String + let page: Int + let pageSize: Int + let isScenicAdmin: Bool + } + + var summaryResponses: [StatisticsSummaryResponse] = [] + var dailyResponses: [DataListPayload] = [] + var nextDailyError: Error? + private(set) var summaryCalls: [SummaryCall] = [] + private(set) var dailyCalls: [DailyCall] = [] + + /// 返回测试汇总数据。 + func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse { + summaryCalls.append(SummaryCall(scenicId: scenicId, range: range, isScenicAdmin: isScenicAdmin)) + return summaryResponses.isEmpty ? StatisticsSummaryResponse() : summaryResponses.removeFirst() + } + + /// 返回测试每日明细数据。 + func dailyList( + scenicId: Int, + startTime: String, + endTime: String, + page: Int, + pageSize: Int, + isScenicAdmin: Bool + ) async throws -> DataListPayload { + dailyCalls.append( + DailyCall( + scenicId: scenicId, + startTime: startTime, + endTime: endTime, + page: page, + pageSize: pageSize, + isScenicAdmin: isScenicAdmin + ) + ) + if let nextDailyError { + self.nextDailyError = nil + throw nextDailyError + } + return dailyResponses.isEmpty ? DataListPayload(total: 0, data: []) : dailyResponses.removeFirst() + } +} + +/// URLSession 顺序响应测试替身。 +private final class SequencedURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + init(responses: [Data]) { + self.responses = responses + } + + /// 依次返回预设响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} + +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkan_iosTests/StorageTests.swift b/suixinkan_iosTests/StorageTests.swift new file mode 100644 index 0000000..d7592fe --- /dev/null +++ b/suixinkan_iosTests/StorageTests.swift @@ -0,0 +1,60 @@ +// +// StorageTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/20. +// + +import XCTest +@testable import suixinkan_ios + +/// 本地缓存 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 + } +} diff --git a/suixinkan_iosTests/TaskAPITests.swift b/suixinkan_iosTests/TaskAPITests.swift new file mode 100644 index 0000000..097b69d --- /dev/null +++ b/suixinkan_iosTests/TaskAPITests.swift @@ -0,0 +1,145 @@ +// +// TaskAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 任务 API 测试,覆盖请求参数、请求体和模型解码。 +final class TaskAPITests: XCTestCase { + /// 测试任务列表接口使用正确 path 和 query。 + func testTaskListUsesExpectedPathAndQuery() async throws { + let session = TaskRecordingURLSession(data: try TestFixture.data(named: "task_list_success")) + let api = TaskAPI(client: APIClient(session: session)) + + _ = try await api.taskList( + scenicId: 88, + page: 0, + pageSize: 0, + taskStatus: -2, + taskName: " 精修 ", + startTime: "2026-06-01", + endTime: "2026-06-30" + ) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/task/list") + let query = taskQueryItems(from: request) + XCTAssertEqual(query["scenic_id"], "88") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(query["task_status"], "0") + XCTAssertEqual(query["task_name"], "精修") + XCTAssertEqual(query["start_time"], "2026-06-01") + XCTAssertEqual(query["end_time"], "2026-06-30") + } + + /// 测试任务详情接口使用正确 path 和 id。 + func testTaskDetailUsesExpectedPath() async throws { + let session = TaskRecordingURLSession(data: try TestFixture.data(named: "task_detail_success")) + let api = TaskAPI(client: APIClient(session: session)) + + let detail = try await api.taskDetail(id: 101) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/task/info") + XCTAssertEqual(taskQueryItems(from: request)["id"], "101") + XCTAssertEqual(detail.urgentHour, 24) + XCTAssertEqual(detail.taskResult.first?.isVideo, true) + } + + /// 测试可关联订单接口使用正确景区参数。 + func testAvailableOrderUsesScenicId() async throws { + let session = TaskRecordingURLSession(data: try TestFixture.data(named: "available_order_success")) + let api = TaskAPI(client: APIClient(session: session)) + + let orders = try await api.availableOrderList(scenicId: 88) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/task/available-order") + XCTAssertEqual(taskQueryItems(from: request)["scenic_id"], "88") + XCTAssertEqual(orders.first?.orderNumber, "ORD-TASK-001") + } + + /// 测试发布任务接口提交正确 body。 + func testAddTaskUsesExpectedBody() async throws { + let session = TaskRecordingURLSession(data: try TestFixture.data(named: "empty_success")) + let api = TaskAPI(client: APIClient(session: session)) + let request = AddTaskRequest( + scenicId: "88", + name: "任务", + orderNumber: "ORD001", + remark: "备注", + urgentHour: 12, + cloudFile: [CloudFileItem(fileId: 1, remark: "")], + uploadFile: [UploadFileItem(fileUrl: "https://cdn.example.com/a.jpg", fileName: "a.jpg", remark: "")] + ) + + try await api.addTask(request) + + let urlRequest = try XCTUnwrap(session.requests.first) + XCTAssertEqual(urlRequest.url?.path, "/api/yf-handset-app/photog/task/create") + let body = try XCTUnwrap(urlRequest.httpBody) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(object["scenic_id"] as? String, "88") + XCTAssertEqual(object["name"] as? String, "任务") + XCTAssertEqual(object["order_number"] as? String, "ORD001") + XCTAssertEqual(object["photog_remark"] as? String, "备注") + XCTAssertEqual(object["urgent_hour"] as? Int, 12) + XCTAssertEqual((object["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 1) + XCTAssertEqual((object["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/a.jpg") + } + + /// 测试云盘文件列表接口使用正确 path 和 query。 + func testCloudFileListUsesExpectedQuery() async throws { + let session = TaskRecordingURLSession(data: try TestFixture.data(named: "cloud_file_list_success")) + let api = TaskAPI(client: APIClient(session: session)) + + let payload = try await api.cloudFileList(parentFolderId: 501, name: "photo", type: 2, orderBy: 2, page: 0, pageSize: 0) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/cloud-driver/list") + let query = taskQueryItems(from: request) + XCTAssertEqual(query["parent_folder_id"], "501") + XCTAssertEqual(query["name"], "photo") + XCTAssertEqual(query["type"], "2") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(payload.list.first?.isFolder, true) + } +} + +/// 任务 API 测试用 URLSession,记录请求并返回固定数据。 +private final class TaskRecordingURLSession: URLSessionProtocol { + let data: Data + private(set) var requests: [URLRequest] = [] + + /// 初始化测试 Session。 + init(data: Data) { + self.data = data + } + + /// 记录请求并返回成功响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response) + } +} + +/// 从请求中提取 query 字典。 +private func taskQueryItems(from request: URLRequest) -> [String: String] { + guard + let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkan_iosTests/TaskViewModelTests.swift b/suixinkan_iosTests/TaskViewModelTests.swift new file mode 100644 index 0000000..0b32c76 --- /dev/null +++ b/suixinkan_iosTests/TaskViewModelTests.swift @@ -0,0 +1,516 @@ +// +// TaskViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import Foundation +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 任务 ViewModel 测试,覆盖列表、发布任务和云盘选择逻辑。 +final class TaskViewModelTests: XCTestCase { + /// 测试无景区时清空任务列表且不请求接口。 + func testTaskManagementClearsWithoutScenic() async throws { + let api = TaskMockService() + api.taskListResponses = [try TestFixture.payload(ListPayload.self, named: "task_list_success")] + let viewModel = TaskManagementViewModel() + + try await viewModel.reload(api: api, scenicId: 88) + XCTAssertFalse(viewModel.tasks.isEmpty) + + try await viewModel.reload(api: api, scenicId: nil) + + XCTAssertTrue(viewModel.tasks.isEmpty) + XCTAssertEqual(viewModel.total, 0) + XCTAssertEqual(viewModel.page, 1) + XCTAssertEqual(api.taskListCalls.count, 1) + XCTAssertEqual(viewModel.errorMessage, "请先选择景区") + } + + /// 测试任务列表首屏和加载更多会更新分页。 + func testTaskManagementReloadAndLoadMore() async throws { + let api = TaskMockService() + api.taskListResponses = [ + try TestFixture.payload(ListPayload.self, named: "task_list_success"), + try TestFixture.payload(ListPayload.self, named: "task_list_page2_success") + ] + let viewModel = TaskManagementViewModel() + + try await viewModel.reload(api: api, scenicId: 88) + viewModel.total = 3 + try await viewModel.loadMore(api: api, scenicId: 88) + + XCTAssertEqual(viewModel.tasks.count, 3) + XCTAssertEqual(viewModel.page, 2) + XCTAssertEqual(api.taskListCalls.map(\.page), [1, 2]) + } + + /// 测试筛选参数会传给任务服务层。 + func testTaskManagementUsesFilters() async throws { + let api = TaskMockService() + api.taskListResponses = [try TestFixture.payload(ListPayload.self, named: "task_list_success")] + let viewModel = TaskManagementViewModel() + viewModel.selectedStatus = .accepted + viewModel.searchText = " 亲子 " + viewModel.startDate = Self.date("2026-06-01") + viewModel.endDate = Self.date("2026-06-30") + + try await viewModel.reload(api: api, scenicId: 88) + + let call = try XCTUnwrap(api.taskListCalls.first) + XCTAssertEqual(call.taskStatus, TaskStatusFilter.accepted.rawValue) + XCTAssertEqual(call.taskName, "亲子") + XCTAssertEqual(call.startTime, "2026-06-01") + XCTAssertEqual(call.endTime, "2026-06-30") + } + + /// 测试任务详情加载成功后保存详情。 + func testTaskDetailLoadsDetail() async throws { + let api = TaskMockService() + api.detailResponse = try TestFixture.payload(TaskDetailResponse.self, named: "task_detail_success") + let viewModel = TaskDetailViewModel() + + await viewModel.load(api: api, taskId: 101) + + XCTAssertEqual(api.detailIds, [101]) + XCTAssertEqual(viewModel.detail?.name, "游客精修任务") + XCTAssertNil(viewModel.errorMessage) + } + + /// 测试发布任务校验必填项和景区。 + func testTaskCreateValidation() async throws { + let api = TaskMockService() + let upload = TaskMockUploadService() + let viewModel = TaskCreateViewModel() + + let noScenic = try await viewModel.submit(api: api, uploadService: upload, scenicId: nil) + XCTAssertFalse(noScenic) + XCTAssertEqual(viewModel.errorMessage, "请先选择景区") + + viewModel.taskName = "任务" + let noRemark = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) + XCTAssertFalse(noRemark) + XCTAssertEqual(viewModel.errorMessage, "请输入任务备注") + } + + /// 测试发布任务会把云盘文件和本地上传文件写入请求。 + func testTaskCreateUploadsLocalFilesBeforeSubmit() async throws { + let api = TaskMockService() + let upload = TaskMockUploadService() + upload.taskUploadURLs = ["https://cdn.example.com/task/a.jpg"] + let viewModel = TaskCreateViewModel() + viewModel.taskName = "任务" + viewModel.remark = "备注" + viewModel.urgentHourText = "6" + viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "")]) + viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg") + + let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) + + XCTAssertTrue(success) + XCTAssertEqual(upload.taskUploads.count, 1) + let request = try XCTUnwrap(api.addTaskRequests.first) + XCTAssertEqual(request.scenicId, "88") + XCTAssertEqual(request.name, "任务") + XCTAssertEqual(request.cloudFile.first?.fileId, 501) + XCTAssertEqual(request.uploadFile.first?.fileUrl, "https://cdn.example.com/task/a.jpg") + XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty) + } + + /// 测试本地文件上传失败时不会提交发布任务。 + func testTaskCreateUploadFailureStopsSubmit() async { + let api = TaskMockService() + let upload = TaskMockUploadService() + upload.uploadError = APIError.networkFailed("上传失败") + let viewModel = TaskCreateViewModel() + viewModel.taskName = "任务" + viewModel.remark = "备注" + viewModel.addLocalFile(data: Data([1]), fileName: "local.jpg") + + do { + _ = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) + XCTFail("Expected upload failure") + } catch APIError.networkFailed(let message) { + XCTAssertEqual(message, "上传失败") + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertTrue(api.addTaskRequests.isEmpty) + XCTAssertEqual(viewModel.selectedLocalFiles.first?.errorMessage, "网络请求失败:上传失败") + } + + /// 测试云盘选择器进入文件夹、分页、选择文件和忽略文件夹选择。 + func testCloudSelectionNavigationAndSelection() async throws { + let api = TaskMockService() + let firstPage = try TestFixture.payload(ListPayload.self, named: "cloud_file_list_success") + let secondPage = ListPayload(total: 3, list: [firstPage.list[1]]) + api.cloudFileResponses = [firstPage, secondPage, firstPage] + let viewModel = TaskCloudFileSelectionViewModel() + + try await viewModel.reload(api: api) + viewModel.total = 3 + try await viewModel.loadMore(api: api) + XCTAssertEqual(viewModel.files.count, 3) + + let folder = firstPage.list[0] + try await viewModel.enterFolder(folder, api: api) + XCTAssertEqual(viewModel.currentFolderId, folder.id) + + viewModel.toggleSelection(folder) + XCTAssertTrue(viewModel.selectedFiles.isEmpty) + + let file = firstPage.list[1] + viewModel.toggleSelection(file) + XCTAssertEqual(viewModel.makeSelectionItems().first?.id, file.id) + XCTAssertEqual(api.cloudFileCalls.map(\.page), [1, 2, 1]) + } + + /// 测试发布任务会加载可关联订单,无景区时清空列表。 + func testTaskCreateLoadAvailableOrders() async throws { + let api = TaskMockService() + api.availableOrders = try TestFixture.payload([AvailableOrderResponse].self, named: "available_order_success") + let viewModel = TaskCreateViewModel() + + await viewModel.loadAvailableOrders(api: api, scenicId: nil) + XCTAssertTrue(viewModel.availableOrders.isEmpty) + XCTAssertTrue(api.availableOrderScenicIds.isEmpty) + + await viewModel.loadAvailableOrders(api: api, scenicId: 88) + XCTAssertEqual(api.availableOrderScenicIds, [88]) + XCTAssertEqual(viewModel.availableOrders.count, 1) + XCTAssertFalse(viewModel.isLoadingOrders) + } + + /// 测试加载可关联订单失败时会清空列表并提示错误。 + func testTaskCreateLoadAvailableOrdersFailure() async { + let api = TaskMockService() + api.availableOrderError = APIError.networkFailed("订单加载失败") + let viewModel = TaskCreateViewModel() + + await viewModel.loadAvailableOrders(api: api, scenicId: 88) + + XCTAssertTrue(viewModel.availableOrders.isEmpty) + XCTAssertEqual(viewModel.errorMessage, "网络请求失败:订单加载失败") + } + + /// 测试上传中的本地附件会阻止提交。 + func testTaskCreateCanSubmitBlocksWhileUploading() { + let viewModel = TaskCreateViewModel() + viewModel.taskName = "任务" + viewModel.remark = "备注" + viewModel.selectedLocalFiles = [ + TaskLocalUploadItem( + id: UUID(), + data: Data([1]), + fileName: "local.jpg", + fileType: 2, + remark: "", + uploadedURL: nil, + progress: 50, + errorMessage: nil + ) + ] + + XCTAssertFalse(viewModel.canSubmit) + XCTAssertTrue(viewModel.selectedLocalFiles.first?.isUploading == true) + XCTAssertEqual(viewModel.selectedLocalFiles.first?.statusText, "上传中 50%") + } + + /// 测试云盘附件合并会去重,移除接口能删除指定文件。 + func testTaskCreateMergeAndRemoveAttachments() { + let viewModel = TaskCreateViewModel() + let file = TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "备注") + viewModel.mergeCloudFiles([file, file]) + viewModel.addLocalFile(data: Data([1]), fileName: "photo.jpg") + guard let localID = viewModel.selectedLocalFiles.first?.id else { + return XCTFail("应存在本地附件") + } + + XCTAssertEqual(viewModel.selectedCloudFiles.count, 1) + viewModel.removeCloudFile(id: 501) + viewModel.removeLocalFile(id: localID) + XCTAssertTrue(viewModel.selectedCloudFiles.isEmpty) + XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty) + } + + /// 测试关联订单和紧急小时会写入发布任务请求。 + func testTaskCreateSubmitIncludesSelectedOrderAndUrgentHour() async throws { + let api = TaskMockService() + let upload = TaskMockUploadService() + let viewModel = TaskCreateViewModel() + viewModel.taskName = " 任务 " + viewModel.remark = "备注" + viewModel.urgentHourText = " 12 " + viewModel.selectOrder(taskAvailableOrder(orderNumber: "ORD-1001")) + + let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) + + XCTAssertTrue(success) + let request = try XCTUnwrap(api.addTaskRequests.first) + XCTAssertEqual(request.name, "任务") + XCTAssertEqual(request.orderNumber, "ORD-1001") + XCTAssertEqual(request.urgentHour, 12) + } + + /// 测试任务名称和紧急小时校验失败时不会提交。 + func testTaskCreateValidationCoversTaskNameAndUrgentHour() async throws { + let api = TaskMockService() + let upload = TaskMockUploadService() + let viewModel = TaskCreateViewModel() + viewModel.remark = "备注" + + let missingName = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) + XCTAssertFalse(missingName) + XCTAssertEqual(viewModel.errorMessage, "请输入任务名称") + + viewModel.taskName = "任务" + viewModel.urgentHourText = "abc" + let invalidHour = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) + XCTAssertFalse(invalidHour) + XCTAssertEqual(viewModel.errorMessage, "请输入正确的紧急小时") + } + + /// 测试本地视频附件会识别为视频类型。 + func testTaskCreateDetectsVideoFileType() { + let viewModel = TaskCreateViewModel() + viewModel.addLocalFile(data: Data([1]), fileName: "clip.mp4") + + XCTAssertEqual(viewModel.selectedLocalFiles.first?.fileType, 1) + } + + /// 测试云盘选择器支持返回上级目录、搜索筛选和取消选择。 + func testCloudSelectionPopToFolderSearchAndDeselect() async throws { + let api = TaskMockService() + let firstPage = try TestFixture.payload(ListPayload.self, named: "cloud_file_list_success") + api.cloudFileResponses = [firstPage, firstPage] + let viewModel = TaskCloudFileSelectionViewModel() + let folder = firstPage.list[0] + let file = firstPage.list[1] + + try await viewModel.enterFolder(folder, api: api) + viewModel.searchText = " photo " + viewModel.selectedFilter = .image + try await viewModel.reload(api: api) + + let call = try XCTUnwrap(api.cloudFileCalls.last) + XCTAssertEqual(call.parentFolderId, folder.id) + XCTAssertEqual(call.name, "photo") + XCTAssertEqual(call.type, TaskCloudFileFilter.image.rawValue) + + viewModel.toggleSelection(file) + XCTAssertTrue(viewModel.isSelected(file)) + viewModel.toggleSelection(file) + XCTAssertFalse(viewModel.isSelected(file)) + + try await viewModel.popToFolder(at: 0, api: api) + XCTAssertEqual(viewModel.currentFolderId, 0) + } + + /// 测试云盘列表加载失败时会清空状态并抛出错误。 + func testCloudSelectionReloadFailureClearsState() async { + let api = TaskMockService() + api.cloudFileError = APIError.networkFailed("云盘失败") + let viewModel = TaskCloudFileSelectionViewModel() + viewModel.files = [CloudDriveFile(id: 1, name: "占位")] + viewModel.total = 5 + viewModel.page = 2 + + do { + try await viewModel.reload(api: api) + XCTFail("加载失败时应抛出错误") + } catch APIError.networkFailed(let message) { + XCTAssertEqual(message, "云盘失败") + } catch { + XCTFail("意外错误: \(error)") + } + + XCTAssertTrue(viewModel.files.isEmpty) + XCTAssertEqual(viewModel.total, 0) + XCTAssertEqual(viewModel.page, 1) + XCTAssertEqual(viewModel.errorMessage, "网络请求失败:云盘失败") + } + + /// 测试云盘没有更多数据时不会继续分页。 + func testCloudSelectionLoadMoreStopsWhenNoMore() async throws { + let api = TaskMockService() + let page = try TestFixture.payload(ListPayload.self, named: "cloud_file_list_success") + api.cloudFileResponses = [page] + let viewModel = TaskCloudFileSelectionViewModel() + + try await viewModel.reload(api: api) + viewModel.total = viewModel.files.count + try await viewModel.loadMore(api: api) + + XCTAssertEqual(api.cloudFileCalls.count, 1) + XCTAssertFalse(viewModel.isLoadingMore) + } + + /// 构造固定日期。 + private static func date(_ text: String) -> Date { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: text)! + } +} + +@MainActor +/// 任务服务测试替身。 +private final class TaskMockService: TaskServing { + struct TaskListCall { + let scenicId: Int + let page: Int + let pageSize: Int + let taskStatus: Int + let taskName: String? + let startTime: String? + let endTime: String? + } + + struct CloudFileCall { + let parentFolderId: Int + let name: String + let type: Int + let orderBy: Int + let page: Int + let pageSize: Int + } + + var taskListResponses: [ListPayload] = [] + var detailResponse: TaskDetailResponse? + var availableOrders: [AvailableOrderResponse] = [] + var availableOrderError: Error? + var cloudFileResponses: [ListPayload] = [] + var cloudFileError: Error? + private(set) var taskListCalls: [TaskListCall] = [] + private(set) var detailIds: [Int] = [] + private(set) var availableOrderScenicIds: [Int] = [] + private(set) var addTaskRequests: [AddTaskRequest] = [] + private(set) var cloudFileCalls: [CloudFileCall] = [] + + /// 记录任务列表请求并返回下一份响应。 + func taskList( + scenicId: Int, + page: Int, + pageSize: Int, + taskStatus: Int, + taskName: String?, + startTime: String?, + endTime: String? + ) async throws -> ListPayload { + taskListCalls.append( + TaskListCall( + scenicId: scenicId, + page: page, + pageSize: pageSize, + taskStatus: taskStatus, + taskName: taskName, + startTime: startTime, + endTime: endTime + ) + ) + return taskListResponses.isEmpty ? ListPayload(total: 0, list: []) : taskListResponses.removeFirst() + } + + /// 记录任务详情请求并返回响应。 + func taskDetail(id: Int) async throws -> TaskDetailResponse { + detailIds.append(id) + return detailResponse! + } + + /// 记录可关联订单请求并返回响应。 + func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] { + availableOrderScenicIds.append(scenicId) + if let availableOrderError { throw availableOrderError } + return availableOrders + } + + /// 记录发布任务请求。 + func addTask(_ request: AddTaskRequest) async throws { + addTaskRequests.append(request) + } + + /// 记录云盘文件请求并返回下一份响应。 + func cloudFileList( + parentFolderId: Int, + name: String, + type: Int, + orderBy: Int, + page: Int, + pageSize: Int + ) async throws -> ListPayload { + cloudFileCalls.append( + CloudFileCall( + parentFolderId: parentFolderId, + name: name, + type: type, + orderBy: orderBy, + page: page, + pageSize: pageSize + ) + ) + if let cloudFileError { throw cloudFileError } + return cloudFileResponses.isEmpty ? ListPayload(total: 0, list: []) : cloudFileResponses.removeFirst() + } +} + +/// 构造可关联订单测试数据。 +private func taskAvailableOrder(orderNumber: String) -> AvailableOrderResponse { + let data = Data(#"{"project_name":"项目","order_number":"\#(orderNumber)","order_status":"1","order_status_label":"已支付","pay_time":"2026-06-24","user_phone":"13800000000"}"#.utf8) + return try! JSONDecoder().decode(AvailableOrderResponse.self, from: data) +} + +@MainActor +/// OSS 上传服务测试替身。 +private final class TaskMockUploadService: OSSUploadServing { + struct TaskUpload { + let data: Data + let fileName: String + let fileType: Int + let scenicId: Int + } + + var taskUploadURLs: [String] = [] + var uploadError: Error? + private(set) var taskUploads: [TaskUpload] = [] + + /// 模拟上传任务附件。 + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + taskUploads.append(TaskUpload(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId)) + onProgress(50) + if let uploadError { + throw uploadError + } + onProgress(100) + return taskUploadURLs.isEmpty ? "https://cdn.example.com/task/default.jpg" : taskUploadURLs.removeFirst() + } + + /// 模拟上传项目图片。 + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 模拟上传头像。 + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 模拟上传实名认证图片。 + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 模拟上传云盘文件。 + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 模拟上传相册文件。 + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 模拟上传打卡点图片。 + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 模拟上传景区申请图片。 + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + + /// 模拟上传银行卡照片。 + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} diff --git a/suixinkan_iosTests/TestFixture.swift b/suixinkan_iosTests/TestFixture.swift new file mode 100644 index 0000000..5022a66 --- /dev/null +++ b/suixinkan_iosTests/TestFixture.swift @@ -0,0 +1,68 @@ +// +// TestFixture.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import Foundation +@testable import suixinkan_ios + +/// 测试 Fixture 读取工具,用于复用旧工程迁移过来的 JSON 响应。 +enum TestFixture { + /// 读取指定名称的 JSON fixture。 + static func data(named name: String, filePath: String = #filePath) throws -> Data { + var directory = URL(fileURLWithPath: filePath).deletingLastPathComponent() + while directory.path != "/" { + let url = directory + .appendingPathComponent("Fixtures") + .appendingPathComponent("\(name).json") + if FileManager.default.fileExists(atPath: url.path) { + return try Data(contentsOf: url) + } + directory.deleteLastPathComponent() + } + let fallback = URL(fileURLWithPath: filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures") + .appendingPathComponent("\(name).json") + return try Data(contentsOf: fallback) + } + + /// 读取并解包后端统一 Envelope。 + static func payload(_ type: T.Type, named name: String) throws -> T { + let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data(named: name)) + guard let payload = envelope.data else { + throw FixtureError.emptyPayload + } + return payload + } +} + +/// 测试 Fixture 错误实体。 +enum FixtureError: Error { + case emptyPayload +} + +extension OSSUploadServing { + /// 旧上传替身默认不关心直播相册入口;Live 专用测试会覆盖此方法记录调用。 + func uploadAliveAlbumFile( + data: Data, + fileName: String, + fileType: Int, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + "" + } + + /// 旧上传替身默认不关心飞手认证入口;飞手认证专用测试会覆盖此方法记录调用。 + func uploadPilotCertificateImage( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + "" + } +} diff --git a/suixinkan_iosTests/ToastCenterTests.swift b/suixinkan_iosTests/ToastCenterTests.swift new file mode 100644 index 0000000..d6f1f3f --- /dev/null +++ b/suixinkan_iosTests/ToastCenterTests.swift @@ -0,0 +1,60 @@ +// +// ToastCenterTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 全局 Toast 状态中心测试,覆盖自动隐藏和连续展示行为。 +final class ToastCenterTests: XCTestCase { + /// 测试 show 会立即写入当前 Toast 文案。 + func testShowStoresMessage() { + let center = ToastCenter(autoDismissNanoseconds: 100_000_000) + + center.show("保存成功") + + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: "保存成功")) + } + + /// 测试 Toast 到达自动隐藏时间后会清空文案。 + func testToastAutoDismissesAfterDelay() async throws { + let center = ToastCenter(autoDismissNanoseconds: 60_000_000) + + center.show("保存成功") + try await Task.sleep(nanoseconds: 90_000_000) + + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil)) + } + + /// 测试连续 show 会覆盖旧文案,并重新计算自动隐藏时间。 + func testRepeatedShowResetsAutoDismissTimer() async throws { + let center = ToastCenter(autoDismissNanoseconds: 100_000_000) + + center.show("第一条") + try await Task.sleep(nanoseconds: 70_000_000) + center.show("第二条") + try await Task.sleep(nanoseconds: 60_000_000) + + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: "第二条")) + + try await Task.sleep(nanoseconds: 70_000_000) + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil)) + } + + /// 测试手动 dismiss 会立即清空文案,并取消当前自动隐藏任务。 + func testDismissClearsMessageImmediately() async throws { + let center = ToastCenter(autoDismissNanoseconds: 100_000_000) + + center.show("保存成功") + center.dismiss() + + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil)) + + try await Task.sleep(nanoseconds: 130_000_000) + XCTAssertEqual(center.snapshotForTests, ToastSnapshot(message: nil)) + } +} diff --git a/suixinkan_iosTests/UploadTests.swift b/suixinkan_iosTests/UploadTests.swift new file mode 100644 index 0000000..c7abc74 --- /dev/null +++ b/suixinkan_iosTests/UploadTests.swift @@ -0,0 +1,125 @@ +// +// UploadTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 上传模块测试,覆盖 OSS 上传策略和 STS 配置接口。 +final class UploadTests: XCTestCase { + /// 测试文件校验能识别空文件、超大文件和不支持扩展名。 + func testOSSUploadPolicyValidatesFileConstraints() { + XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 0, fileName: "avatar.jpg")) { error in + XCTAssertEqual(error as? OSSUploadError, .emptyFile) + } + XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: OSSUploadPolicy.maxFileSize + 1, fileName: "avatar.jpg")) { error in + XCTAssertEqual(error as? OSSUploadError, .fileTooLarge) + } + XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.txt")) { error in + XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType) + } + XCTAssertNoThrow(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.heic")) + } + + /// 测试 objectKey 按业务模块生成稳定路径并清洗文件名。 + func testOSSUploadPolicyBuildsObjectKeys() { + let date = Date(timeIntervalSince1970: 1_767_225_600) + let uuid = UUID(uuidString: "12345678-1234-1234-1234-1234567890ab")! + let timeZone = TimeZone(secondsFromGMT: 0)! + + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "a/b.jpg", scenicId: 9, moduleType: "user_avatar", date: date, uuid: uuid, timeZone: timeZone), + "avatar/20260101/9/123456781234123412341234567890AB_a_b.jpg" + ) + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "id.jpg", scenicId: 9, moduleType: "real_name", date: date, uuid: uuid, timeZone: timeZone), + "real_name/20260101/9/123456781234123412341234567890AB_id.jpg" + ) + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone), + "task_upload/20260101/9/123456781234123412341234567890AB_video.mp4" + ) + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "bank.jpg", scenicId: 9, moduleType: "bank_card", date: date, uuid: uuid, timeZone: timeZone), + "bank_card/20260101/9/123456781234123412341234567890AB_bank.jpg" + ) + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "cover.jpg", scenicId: 9, moduleType: "project", date: date, uuid: uuid, timeZone: timeZone), + "project/20260101/9/123456781234123412341234567890AB_cover.jpg" + ) + XCTAssertEqual( + OSSUploadPolicy.objectKey(fileName: "live.mp4", scenicId: 9, moduleType: "alive_album", date: date, uuid: uuid, timeZone: timeZone), + "live_albums/20260101/9/123456781234123412341234567890AB_live.mp4" + ) + } + + /// 测试 MIME 类型推断和 URL 拼接规则。 + func testOSSUploadPolicyContentTypeAndURLJoin() { + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.jpg", fileType: 2), "image/jpeg") + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.png", fileType: 2), "image/png") + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.mp4", fileType: 1), "video/mp4") + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 1), "video/mp4") + XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 2), "image/jpeg") + XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg") + XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com/", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg") + } + + /// 测试 STS token 接口使用正确路径、bucket 参数并兼容宽松字段类型。 + func testUploadAPIRequestsSTSConfig() async throws { + let response = """ + { + "code": 100000, + "msg": "success", + "data": { + "base_url": 123, + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "region": "cn-hangzhou", + "bucket": "vipsky", + "expire_seconds": "900", + "credentials": { + "access_key_id": 456, + "access_key_secret": "secret", + "security_token": "token" + } + } + } + """.data(using: .utf8)! + let session = UploadURLSession(responses: [response]) + let api = UploadAPI(client: APIClient(session: session)) + + let config = try await api.aliyunOSSBucket() + + XCTAssertEqual(config.baseUrl, "123") + XCTAssertEqual(config.expireSeconds, 900) + XCTAssertEqual(config.credentials.accessKeyId, "456") + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/app/config/get-sts-token") + XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "vipsky") + } +} + +/// 上传测试用 URLSession 替身,按顺序返回预设响应。 +private final class UploadURLSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化顺序响应。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回下一份响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data() : responses.removeFirst() + return ( + data, + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} diff --git a/suixinkan_iosTests/WalletViewModelTests.swift b/suixinkan_iosTests/WalletViewModelTests.swift new file mode 100644 index 0000000..e7f9ead --- /dev/null +++ b/suixinkan_iosTests/WalletViewModelTests.swift @@ -0,0 +1,223 @@ +// +// WalletViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/22. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 钱包模块测试,覆盖首页、提现资格、银行卡上传和积分兑换。 +final class WalletViewModelTests: XCTestCase { + /// 测试钱包首页会加载汇总和收益明细。 + func testWalletInitialLoadFetchesSummaryAndEarnings() async { + let api = WalletMockAPI() + api.summary = WalletSummaryResponse(amountTotal: "100", amountCurrentBalance: "80", amountWithdrawable: "60") + api.earningResponses = [WalletEarningDetailResponse(total: 1, list: [WalletEarningDetailGroup(date: "2026-06-22")])] + let viewModel = WalletViewModel() + + await viewModel.loadInitial(api: api, staffId: 7) + + XCTAssertEqual(viewModel.withdrawableText, "¥ 60") + XCTAssertEqual(api.pointOverviewStaffIds, [7]) + XCTAssertEqual(api.earningQueries.count, 1) + XCTAssertEqual(viewModel.earningsGroups.map(\.date), ["2026-06-22"]) + } + + /// 测试切换到提现记录 Tab 会请求提现分页。 + func testSelectWithdrawTabLoadsWithdrawRecords() async { + let api = WalletMockAPI() + let viewModel = WalletViewModel() + + await viewModel.selectTab(.withdraws, api: api) + + XCTAssertEqual(api.withdrawListPages, [1]) + } + + /// 测试提现资格流根据实名认证和银行卡状态进入正确页面。 + func testWithdrawDecisionRoutesByRealNameAndBankCardState() async throws { + let profile = WalletRealNameMockAPI() + let wallet = WalletMockAPI() + let viewModel = WalletViewModel() + + profile.response = RealNameInfoResponse(realNameInfo: nil) + var decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet) + XCTAssertEqual(decision, .route(.realNameAuth)) + + profile.response = try Self.realNameInfo(status: 2) + wallet.bankCardResponse = BankCardInfoResponse(bankCard: nil) + decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet) + XCTAssertEqual(decision, .route(.bankCardSettings)) + + wallet.bankCardResponse = try BankCardInfoResponse(bankCard: Self.bankCard(status: 2)) + decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet) + XCTAssertEqual(decision, .route(.withdrawApply)) + } + + /// 测试提现申请提交会校验金额并传递规范化金额。 + func testWithdrawApplySubmitsNormalizedAmount() async throws { + let api = WalletMockAPI() + api.withdrawInfoResponse = try Self.withdrawInfo(withdrawable: "100") + let viewModel = WithdrawApplyViewModel() + await viewModel.load(api: api) + viewModel.amountText = "12.345" + viewModel.smsCode = "8899" + + let success = await viewModel.submit(api: api) + + XCTAssertTrue(success) + XCTAssertEqual(api.withdrawApplyBodies.map(\.amount), ["12.35"]) + XCTAssertEqual(api.withdrawApplyBodies.map(\.smsCode), ["8899"]) + } + + /// 测试银行卡设置会先上传正反面照片,再提交最终 URL。 + func testWithdrawalSettingsUploadsImagesBeforeSubmit() async { + let api = WalletMockAPI() + let uploader = WalletUploadMock() + let viewModel = WithdrawalSettingsViewModel() + viewModel.realName = "张三" + viewModel.cardNumber = "622200" + viewModel.bankName = "中国银行" + viewModel.branchName = "杭州支行" + viewModel.provinceCode = "330000" + viewModel.cityCode = "330100" + viewModel.smsCode = "1234" + viewModel.frontImageData = Data([1]) + viewModel.backImageData = Data([2]) + + let success = await viewModel.submit(api: api, uploader: uploader, scenicId: 9) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.bankUploads.count, 2) + XCTAssertEqual(api.updateBankRequests.first?.frontUrl, "https://cdn.example.com/bank_card_front.jpg") + XCTAssertEqual(api.updateBankRequests.first?.backUrl, "https://cdn.example.com/bank_card_back.jpg") + } + + /// 测试积分兑换不能超过可兑换积分,成功后会刷新列表。 + func testPointsRedemptionValidationAndSubmit() async { + let api = WalletMockAPI() + api.pointOverviewResponse = PointOverviewResponse(withdrawnPoints: 100) + let viewModel = PointsRedemptionViewModel() + await viewModel.load(api: api, staffId: 7) + viewModel.pointsText = "120" + + var success = await viewModel.submit(api: api, staffId: 7) + XCTAssertFalse(success) + XCTAssertEqual(viewModel.errorMessage, "兑换积分不能超过可兑换积分") + + viewModel.pointsText = "80" + success = await viewModel.submit(api: api, staffId: 7) + XCTAssertTrue(success) + XCTAssertEqual(api.pointApplyBodies.map(\.points), [80]) + } + + /// 从 JSON 创建实名认证响应。 + private static func realNameInfo(status: Int) throws -> RealNameInfoResponse { + let data = """ + {"real_name_info":{"real_name":"张三","id_card_no":"11010519491231002X","audit_status":\(status),"is_long_valid":1}} + """.data(using: .utf8)! + return try JSONDecoder().decode(RealNameInfoResponse.self, from: data) + } + + /// 从 JSON 创建银行卡信息。 + private static func bankCard(status: Int) throws -> WalletBankCardInfo { + let data = """ + {"real_name":"张三","bank_name":"中国银行","branch_name":"杭州支行","card_number":"622200","audit_status":\(status),"audit_status_label":"通过"} + """.data(using: .utf8)! + return try JSONDecoder().decode(WalletBankCardInfo.self, from: data) + } + + /// 从 JSON 创建提现信息。 + fileprivate static func withdrawInfo(withdrawable: String) throws -> WithdrawInfoResponse { + let data = """ + {"amount_withdrawable":"\(withdrawable)","min_withdraw_amount":"1","max_single_withdraw_amount":"1000","max_daily_withdraw_amount":"1000","user_phone":"13800000000","bank_card":{"real_name":"张三","bank_name":"中国银行","card_number":"622200"},"withdraw_info":[]} + """.data(using: .utf8)! + return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data) + } +} + +@MainActor +/// 钱包 API 测试替身。 +private final class WalletMockAPI: WalletServing { + var summary = WalletSummaryResponse() + var earningResponses: [WalletEarningDetailResponse] = [] + var withdrawResponses: [WalletWithdrawListResponse] = [] + var withdrawInfoResponse: WithdrawInfoResponse? + var bankCardResponse = BankCardInfoResponse(bankCard: nil) + var pointOverviewResponse = PointOverviewResponse() + private(set) var earningQueries: [(start: String, end: String, page: Int)] = [] + private(set) var withdrawListPages: [Int] = [] + private(set) var withdrawApplyBodies: [(amount: String, smsCode: String)] = [] + private(set) var updateBankRequests: [UpdateBankInfoRequest] = [] + private(set) var pointOverviewStaffIds: [Int] = [] + private(set) var pointApplyBodies: [(points: Int, remark: String)] = [] + + func walletSummary(type: Int) async throws -> WalletSummaryResponse { summary } + func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse { + earningQueries.append((startDate, endDate, page)) + return earningResponses.isEmpty ? WalletEarningDetailResponse() : earningResponses.removeFirst() + } + func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { + withdrawListPages.append(page) + return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst() + } + func withdrawInfo() async throws -> WithdrawInfoResponse { + if let withdrawInfoResponse { return withdrawInfoResponse } + return try WalletViewModelTests.withdrawInfo(withdrawable: "0") + } + func withdrawSendSms() async throws {} + func withdrawApply(amount: String, smsCode: String) async throws { + withdrawApplyBodies.append((amount, smsCode)) + } + func bankCardInfo() async throws -> BankCardInfoResponse { bankCardResponse } + func bankList() async throws -> BankListResponse { BankListResponse(banks: ["中国银行"]) } + func areas() async throws -> [AreaNode] { [] } + func bankCardVerifyCode() async throws {} + func updateBankInfo(_ request: UpdateBankInfoRequest) async throws { + updateBankRequests.append(request) + } + func pointOverview(staffId: Int) async throws -> PointOverviewResponse { + pointOverviewStaffIds.append(staffId) + return pointOverviewResponse + } + func pointWithdrawApply(points: Int, remark: String) async throws { + pointApplyBodies.append((points, remark)) + } + func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { + PointWithdrawListResponse() + } +} + +@MainActor +/// 钱包实名认证测试替身。 +private final class WalletRealNameMockAPI: WalletRealNameServing { + var response = RealNameInfoResponse(realNameInfo: nil) + + /// 返回预设实名认证响应。 + func realNameInfo() async throws -> RealNameInfoResponse { + response + } +} + +@MainActor +/// 钱包上传测试替身。 +private final class WalletUploadMock: OSSUploadServing { + private(set) var bankUploads: [String] = [] + + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + bankUploads.append(fileName) + onProgress(100) + return "https://cdn.example.com/\(fileName)" + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} diff --git a/suixinkan_iosTests/WithdrawalAudit/WithdrawalAuditViewModelTests.swift b/suixinkan_iosTests/WithdrawalAudit/WithdrawalAuditViewModelTests.swift new file mode 100644 index 0000000..9d85fdb --- /dev/null +++ b/suixinkan_iosTests/WithdrawalAudit/WithdrawalAuditViewModelTests.swift @@ -0,0 +1,125 @@ +// +// WithdrawalAuditViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/25. +// + +import XCTest +@testable import suixinkan_ios + +@MainActor +/// 提现审核测试,覆盖分页、筛选和失败清理。 +final class WithdrawalAuditViewModelTests: XCTestCase { + /// 测试首次加载、筛选统计和分页加载。 + func testLoadFilterAndLoadMore() async throws { + let api = WithdrawalAuditWalletMock() + api.withdrawResponses = [ + WalletWithdrawListResponse(total: 3, item: [ + try record(id: 1, status: "审核中"), + try record(id: 2, status: "已到账") + ]), + WalletWithdrawListResponse(total: 3, item: [ + try record(id: 3, status: "待打款") + ]) + ] + let viewModel = WithdrawalAuditViewModel() + + await viewModel.reload(api: api) + + XCTAssertEqual(api.withdrawPages, [1]) + XCTAssertEqual(viewModel.totalCount, 3) + XCTAssertEqual(viewModel.processingCount, 1) + XCTAssertEqual(viewModel.completedCount, 1) + XCTAssertTrue(viewModel.hasMore) + + viewModel.selectFilter(.processing) + XCTAssertEqual(viewModel.filteredRecords.map(\.id), [1]) + + await viewModel.loadMore(api: api) + + XCTAssertEqual(api.withdrawPages, [1, 2]) + XCTAssertEqual(viewModel.records.map(\.id), [1, 2, 3]) + XCTAssertEqual(viewModel.processingCount, 2) + XCTAssertFalse(viewModel.hasMore) + } + + /// 测试刷新失败会清空旧记录和分页状态。 + func testReloadFailureClearsStaleRecordsAndPaging() async throws { + let api = WithdrawalAuditWalletMock() + api.withdrawResponses = [ + WalletWithdrawListResponse(total: 2, item: [try record(id: 1, status: "审核中")]) + ] + let viewModel = WithdrawalAuditViewModel() + + await viewModel.reload(api: api) + XCTAssertEqual(viewModel.records.map(\.id), [1]) + XCTAssertTrue(viewModel.hasMore) + + api.withdrawError = TestError.sample + await viewModel.reload(api: api) + + XCTAssertTrue(viewModel.records.isEmpty) + XCTAssertFalse(viewModel.hasMore) + XCTAssertTrue(viewModel.loadFailed) + XCTAssertEqual(viewModel.loadFailureReason, TestError.sample.localizedDescription) + } + + /// 测试空列表和尾页边界不会继续加载。 + func testEmptyListAndLastPageDoNotLoadMore() async { + let api = WithdrawalAuditWalletMock() + api.withdrawResponses = [WalletWithdrawListResponse(total: 0, item: [])] + let viewModel = WithdrawalAuditViewModel() + + await viewModel.reload(api: api) + await viewModel.loadMore(api: api) + + XCTAssertTrue(viewModel.records.isEmpty) + XCTAssertEqual(api.withdrawPages, [1]) + XCTAssertFalse(viewModel.hasMore) + } + + private func record(id: Int64, amount: String = "10", status: String) throws -> WalletWithdrawRecord { + let data = """ + {"id":\(id),"amount":"\(amount)","created_at":"2026-06-25 10:00","status_label":"\(status)","expected_at":"2026-06-26","audit_time":"2026-06-25 11:00","completed_at":""} + """.data(using: .utf8)! + return try JSONDecoder().decode(WalletWithdrawRecord.self, from: data) + } +} + +@MainActor +private final class WithdrawalAuditWalletMock: WalletServing { + var withdrawResponses: [WalletWithdrawListResponse] = [] + var withdrawError: Error? + private(set) var withdrawPages: [Int] = [] + + func walletSummary(type: Int) async throws -> WalletSummaryResponse { WalletSummaryResponse() } + func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse { WalletEarningDetailResponse() } + func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { + withdrawPages.append(page) + if let withdrawError { throw withdrawError } + return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst() + } + func withdrawInfo() async throws -> WithdrawInfoResponse { + let data = #"{"amount_withdrawable":"0","min_withdraw_amount":"1","max_single_withdraw_amount":"100","max_daily_withdraw_amount":"100","user_phone":"13800000000","bank_card":{},"withdraw_info":[]}"#.data(using: .utf8)! + return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data) + } + func withdrawSendSms() async throws {} + func withdrawApply(amount: String, smsCode: String) async throws {} + func bankCardInfo() async throws -> BankCardInfoResponse { BankCardInfoResponse(bankCard: nil) } + func bankList() async throws -> BankListResponse { BankListResponse() } + func areas() async throws -> [AreaNode] { [] } + func bankCardVerifyCode() async throws {} + func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {} + func pointOverview(staffId: Int) async throws -> PointOverviewResponse { PointOverviewResponse() } + func pointWithdrawApply(points: Int, remark: String) async throws {} + func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { PointWithdrawListResponse() } +} + +private enum TestError: LocalizedError { + case sample + + var errorDescription: String? { + "测试错误" + } +} diff --git a/功能同步Checklist.md b/功能同步Checklist.md new file mode 100644 index 0000000..67b67ac --- /dev/null +++ b/功能同步Checklist.md @@ -0,0 +1,152 @@ +# 功能同步 Checklist(UIKit 工程) + +更新时间:2026-06-26 +工程:`suixinkan_ios_new_uikit`(Swift + UIKit + SnapKit) + +## UIKit 同步进度摘要 + +| 阶段 | 状态 | +| --- | --- | +| 阶段 0 工程脚手架 | 完成 | +| 阶段 1 Core 基础设施 | 完成 | +| 阶段 2 登录与会话 | 完成 | +| 阶段 3 Tab 壳与导航 | 完成 | +| 阶段 4 四 Tab 主功能(里程碑 1) | 完成 | +| 阶段 5 首页子业务模块 | 完成 | +| 阶段 6 平台能力(Lottie/Push/AMap 占位) | 完成 | +| 阶段 7 单元测试验收 | 完成(`xcodebuild test` 全绿) | + +## 状态说明 + +- 完成:UI、核心功能、文档和必要单元测试已同步,并通过编译验证。 +- 部分完成:已有入口、壳、路由或部分主流程,但旧工程完整业务尚未迁移。 +- 未开始:new 工程尚无对应真实业务页面或服务。 +- 不迁移:已明确不纳入 iOS 迁移范围。 + +## 基础架构 + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | App 根结构 | `RootView` 按登录态切换登录页/主 Tab;主界面使用 `TabView`,每个 Tab 独立 `NavigationStack`。 | 持续随业务路由扩展。 | +| 完成 | Tab 导航 | 首页、订单、数据、我的四个 Tab;push 子页面时隐藏 TabBar。 | 后续新增真实子页面时继续复用 `AppRoute`。 | +| 完成 | 全局状态拆分 | `AppSession`、`AccountContext`、`PermissionContext`、`ScenicSpotContext`、`AppRouter`、`ToastCenter` 拆分。 | 继续避免新增大而全全局状态。 | +| 完成 | 登录态缓存 | token 使用 Keychain;账号快照、上次手机号、偏好使用 UserDefaults;冷启动恢复登录态。 | 后续业务缓存按账号和景区/门店作用域隔离。 | +| 完成 | 网络框架 | `APIClient`、`APIRequest`、`APIEnvelope`、统一错误处理和 token 注入。 | 业务 API 继续按模块拆分。 | +| 完成 | OSS 上传 | `UploadAPI`、`OSSUploadService`、上传策略、头像、实名图片、任务附件、云盘文件、相册文件、素材/样片文件、项目图片上传处理。 | 后续新上传场景继续复用。 | +| 完成 | 网络图片 | 新增 `RemoteImage` / `RemoteAvatarImage`,当前网络图片展示均使用 Kingfisher。 | 后续新页面禁止直接使用 `AsyncImage` 加载网络图片。 | +| 完成 | 设计常量 | `AppDesign`、`AppMetrics` 统一颜色、字号、间距等通用尺寸。 | 继续替换新页面中的零散常量。 | +| 完成 | 文档规范 | 已有 `AGENTS.md` 约束;现有模块均有模块说明文档。 | 后续每个新功能模块同步新增模块 md。 | + +## 登录与账号上下文 + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 登录页 | 登录 UI、手机号/密码登录、协议勾选、上次手机号恢复。 | 后续如有验证码登录再迁移。 | +| 完成 | 多账号选择 | 登录后多账号选择、临时 token 内存保存、选择后写正式 token。 | 继续跟随后端账号结构变化维护。 | +| 完成 | 账号上下文 | 用户信息、角色权限、景区列表、门店列表、当前景区/门店选择和恢复。 | 后续业务模块按当前上下文取数。 | +| 完成 | 景点/打卡点上下文 | 当前景区下景点/打卡点懒加载,失败不影响登录态。 | 打卡点管理页面已接入,后续可继续增强地图交互。 | +| 完成 | 权限菜单数据 | 递归解析角色权限、URI 去重、按旧工程顺序排序。 | 新 URI 出现时补路由映射或标记不支持。 | + +## 首页 + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 首页主 UI | 顶部景区、工作状态、位置上报卡、快捷操作、门店卡、常用应用网格。 | 视觉可随旧工程细节继续微调。 | +| 完成 | 首页权限菜单 | 从 `PermissionContext` 构建菜单,支持 URI 别名去重和旧工程排序。 | 未知 URI 继续通过诊断报告跟踪。 | +| 完成 | 常用应用 | 默认常用应用、添加/移除、UserDefaults 持久化、按权限过滤。 | 后续可加入拖拽排序。 | +| 完成 | 更多功能 | 全部功能页、常用应用增删、首页入口跳转。 | 未迁移功能仍进入占位页。 | +| 完成 | 首页路由壳 | 已迁移入口跳真实页面;订单/数据入口切换 Tab;未知入口安全占位。 | 按模块逐步替换占位页。 | +| 完成 | 景区选择入口 | 旧工程同款景区选择页、搜索、定位距离、景区卡片、封面、地址、营业状态、切换景区并持久化。 | 后续如有景区选择视觉细节变化再微调。 | +| 完成 | 权限申请入口 | `permission_apply`、`permission_apply_status`、`scenicapplication` 已接真实页面。 | 景区结算仍按独立模块后续迁移。 | + +## 订单 Tab + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 订单 Tab 根页面 | 订单管理/核销订单分段入口。 | 后续继续补长尾订单流程。 | +| 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 无。 | +| 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 无。 | +| 完成 | 扫码核销 | AVFoundation 扫码、订单号解析、命中高亮、二次确认后核销。 | 后续可继续补扫码 UI 细节。 | +| 完成 | 订单详情 | 订单管理详情、核销订单详情、详情接口和列表摘要兜底。 | 无。 | +| 完成 | 押金订单 | 押金订单列表、手输订单号查详情、押金详情、押金核销、押金退款、押金拍摄信息。 | 后续可继续补押金订单 UI 细节和异常空态。 | +| 完成 | 退款流程 | 普通订单退款入口规则、全额/部分退款金额校验、退款提交和详情刷新。 | 复杂退款审核进度如后端需要可单独补。 | +| 完成 | 历史拍摄 | 多点位历史拍摄项目、拍摄点和媒体展示。 | 仅展示已有媒体,不做上传、删除、下载或编辑。 | +| 完成 | 任务上传 | 多点旅拍订单选择已核销打卡点、云盘素材、本地图片/视频 OSS 上传并提交素材。 | 仅 `orderType == 19` 展示。 | +| 完成 | 视频预告/尾片上传 | 进入订单尾片引导页,并复用相册预览上传。 | 没有独立订单尾片接口,暂不做尾片审核进度。 | + +## 数据 Tab + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 数据看板 | 今日、昨日、7 日、本月时间段切换;统计汇总卡;每日明细。 | 后续按旧工程继续补图表细节。 | +| 完成 | 数据分页 | 日数据分页加载、切换时间段重置分页、加载失败保留当前数据。 | 持续跟随后端字段变化。 | +| 完成 | 角色接口分流 | 摄影师/景区管理员两套统计接口。 | 其他角色如有特殊逻辑再补。 | + +## 个人中心 + +| 状态 | 模块 | 同步内容 | 剩余事项 | +| --- | --- | --- | --- | +| 完成 | 个人信息首页 | 头像、昵称、UID、姓名、手机号、账号状态、当前景区、退出登录。 | 后续可继续细化旧工程视觉。 | +| 完成 | 头像上传 | `PhotosPicker` 选图、压缩、OSS 上传、回写头像 URL、刷新账号快照。 | 后续可补裁剪体验。 | +| 完成 | 昵称编辑 | 昵称校验、保存、同步 `AccountContext`。 | 无。 | +| 完成 | 修改密码 | 密码弹窗、最小长度校验、接口提交。 | 无。 | +| 完成 | 账号切换 | 可切换账号列表、默认选中、set-user、刷新登录上下文。 | 无。 | +| 完成 | 实名认证 | 信息加载、短信验证码、身份证校验、证件图片选择、OSS 上传、提交审核。 | 后续可补示例图或拍照入口。 | +| 完成 | 系统设置 | 关于我们、版本号、下载链接、协议入口。 | 若旧工程有更多设置项需继续核对。 | +| 完成 | 协议页 | 关于我们、用户协议、隐私政策、钱包协议页面。 | 无。 | + +## 首页子业务模块 + +| 状态 | 模块 | 当前情况 | 后续事项 | +| --- | --- | --- | --- | +| 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 | +| 完成 | 提现审核 | 首页入口已接真实页面,复用提现记录接口展示审核列表、状态筛选、分页和详情时间线。 | 不做管理员审批操作,旧 Android 未体现对应操作接口。 | +| 完成 | 消息中心 | 消息列表、未读筛选、游标分页、已读/全部已读、详情和删除已接入。 | 本轮不新增后端未体现的批量接口。 | +| 完成 | 排队管理 | `/scenic-queue` / `queue_management` 已接真实页面;支持队列列表、叫号、过号、完成、重新排队、用户标记、设置、二维码、日志、实时监听和页面离开后的短轮询。 | 后续可继续按真实设备验证语音播报和后台活跃策略。 | +| 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 | +| 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 | +| 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 | +| 完成 | 发布任务 | 发布表单、选择订单、选择云盘文件、本地图片/视频 OSS 上传、提交任务。 | 完整云盘资产管理已由相册云盘模块接管,任务页只保留发布所需选择能力。 | +| 完成 | 日程管理 | 月份切换、日期标记、日程列表、下拉刷新、删除日程、新增排班、关联订单或手填订单号兜底。 | 后续如旧工程有排班详情页或高级重复规则再继续迁移。 | +| 完成 | 打卡点管理 | 列表、筛选、分页、详情、二维码、新建、编辑、删除、前台定位选点和图片 OSS 上传已接入。 | 后续可把高德地图完整选点 UI 替换进编辑页。 | +| 完成 | 摄影师项目管理 | `pm` / `project_edit` 已接真实页面;支持列表、搜索、分页、详情、新建、编辑、删除和项目图片 OSS 上传。 | 后续可继续补项目数据分析、项目订单深层流程。 | +| 完成 | 店铺项目管理 | `pm_manager` 已接真实页面;支持店铺项目列表、详情、新建、编辑、删除、多点位项目和押金项目。 | 后续如有店铺后台审核扩展再单独迁移。 | +| 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 | +| 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 | +| 完成 | 相册预览上传 | `album_trailer` 入口已接真实页面;支持选择相册、本地图片/视频 OSS 上传、上传后入库到相册。 | 不恢复旧工程手填 URL 主流程。 | +| 完成 | 素材管理 | 素材列表、关键词/审核状态筛选、分页、详情、上传素材、编辑素材、上下架、删除、标签输入、素材文件 OSS 上传。 | 无。 | +| 完成 | 样片管理 | 样片列表、关键词/审核状态筛选、分页、详情、上下架、删除、上传样片、关联项目选择、样片文件 OSS 上传。 | 完整项目管理、样片编辑、视频播放器增强后续单独迁移。 | +| 完成 | 直播管理/直播相册 | `live_stream_management` 和 `live_album` 已接真实页面;支持直播列表、创建、详情、复制外部推流地址、开始/暂停/结束、推流模式切换、直播详情可播放 URL 预览、直播相册视频播放,以及直播相册筛选、上传素材、预览、删除相册和删除单个素材。 | 已按旧 iOS 对齐:不新增真推流 SDK,`push_url` 仅作为 OBS/第三方工具外部推流地址;页面保留推流诊断适配层,真 RTMP/RTC 推流 SDK 仍需后续单独选型和真机联调。 | +| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | +| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | +| 完成 | 景区结算 | 景区结算申请、金额/备注、多景区提交和结算审核记录已接入。 | 审核记录复用景区申请/权限申请记录,不新增后端未体现的详情审批操作。 | +| 完成 | 运营区域 | `operating-area` 已接真实页面;按店铺/景区管理员模式加载围栏,解析 `business_map_area` 并只读展示地图或模拟器坐标摘要。 | 本轮不做区域新增、编辑、删除或绘制保存。 | +| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | +| 完成 | 注册邀请 | 邀请码、邀请链接、二维码、复制链接、规则展示、邀请记录和奖励记录已接入。 | 邀请二维码不落盘,后续如需分享图片再临时生成。 | +| 完成 | 飞手认证 | `pilot_cert` 已接真实页面;支持实名状态、认证详情、验证码、证件图 OSS 上传、首次提交和驳回编辑。 | 不包含 DJI/飞控 SDK 或真实飞行控制。 | +| 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 | + +## 旧工程能力尚未系统迁移 + +| 状态 | 模块 | 说明 | +| --- | --- | --- | +| 完成 | Assets 模块 | 已迁移相册管理、相册预览上传、相册云盘、素材管理、样片管理和上传样片。 | +| 未开始 | Core/Map | 旧工程地图相关能力尚未迁移。 | +| 未开始 | Core/Push | 旧工程推送能力尚未迁移。 | +| 完成 | Core/Queue | 已迁移排队 WebSocket、远端叫号去重、语音播报和短轮询运行时。 | +| 未开始 | Core/Validation | 旧工程独立校验能力尚未系统迁移;当前只迁移了实名身份证校验等必要逻辑。 | +| 未开始 | UIKit 旧代码 | 旧工程 UIKit 目录下的兼容/旧版页面尚未迁移,除非后续明确仍需保留。 | + +## 最近验证记录 + +已通过: + +```bash +xcodebuild test -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'platform=iOS Simulator,name=iPhone 17' +xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination 'generic/platform=iOS Simulator' +``` + +## 下一批建议迁移顺序 + +1. 直播真推流 SDK:旧 iOS 未接入真推流 SDK,当前已按旧 iOS 对齐为外部 `push_url` + 推流诊断;如后续需要真 SDK,可在腾讯/TRTC、RTMP SDK 等方案明确后替换 `LivePushAdapter` 实现并补真机联调。 +2. 订单尾片深层能力:等后端明确独立尾片接口后,再补自动关联订单、审核进度和视频播放器增强。