Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1175066b9 | ||
|
|
5cf4409bad | ||
|
|
6fe9928b49 | ||
|
|
1cdce616e4 | ||
|
|
212607fc82 | ||
|
|
0cf0a41a60 | ||
|
|
f863004c8d | ||
|
|
61c56c7adb | ||
|
|
413a9603a4 | ||
|
|
f87b13cc05 | ||
|
|
0e0415eec4 |
@@ -1,10 +1,17 @@
|
||||
platform :ios, '16.0'
|
||||
|
||||
project 'suixinkan.xcodeproj',
|
||||
'Debug' => :debug,
|
||||
'SimulatorDebug' => :debug,
|
||||
'Release' => :release
|
||||
|
||||
target 'suixinkan' do
|
||||
use_frameworks!
|
||||
pod 'AMapLocation-NO-IDFA'
|
||||
pod 'AMap3DMap-NO-IDFA'
|
||||
pod 'AMapSearch-NO-IDFA'
|
||||
# 高德 SDK 未提供 arm64-simulator slice,仅在真机配置中接入。
|
||||
pod 'AMapFoundation-NO-IDFA', :configurations => ['Debug', 'Release']
|
||||
pod 'AMapLocation-NO-IDFA', :configurations => ['Debug', 'Release']
|
||||
pod 'AMap3DMap-NO-IDFA', :configurations => ['Debug', 'Release']
|
||||
pod 'AMapSearch-NO-IDFA', :configurations => ['Debug', 'Release']
|
||||
pod 'UMCommon'
|
||||
pod 'UMDevice'
|
||||
pod 'UMAPM'
|
||||
@@ -24,6 +31,9 @@ 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'
|
||||
if target.name == 'Pods-suixinkanTests' && config.name == 'SimulatorDebug'
|
||||
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = ''
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+2
-1
@@ -58,6 +58,7 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- AMap3DMap-NO-IDFA
|
||||
- AMapFoundation-NO-IDFA
|
||||
- AMapLocation-NO-IDFA
|
||||
- AMapSearch-NO-IDFA
|
||||
- IQKeyboardManagerSwift
|
||||
@@ -109,6 +110,6 @@ SPEC CHECKSUMS:
|
||||
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
|
||||
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
|
||||
|
||||
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412
|
||||
PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
Generated
+2
-1
@@ -58,6 +58,7 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- AMap3DMap-NO-IDFA
|
||||
- AMapFoundation-NO-IDFA
|
||||
- AMapLocation-NO-IDFA
|
||||
- AMapSearch-NO-IDFA
|
||||
- IQKeyboardManagerSwift
|
||||
@@ -109,6 +110,6 @@ SPEC CHECKSUMS:
|
||||
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
|
||||
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
|
||||
|
||||
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412
|
||||
PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
Generated
+2932
-2135
File diff suppressed because it is too large
Load Diff
+9
@@ -0,0 +1,9 @@
|
||||
${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-frameworks.sh
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework
|
||||
${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardCore.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardManagerSwift.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardNotification.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardReturnManager.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardToolbar.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardToolbarManager.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQTextInputViewNotification.framework
|
||||
${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQTextView.framework
|
||||
@@ -195,6 +195,16 @@ if [[ "$CONFIGURATION" == "Release" ]]; then
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework"
|
||||
fi
|
||||
if [[ "$CONFIGURATION" == "SimulatorDebug" ]]; then
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardCore/IQKeyboardCore.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework"
|
||||
install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework"
|
||||
fi
|
||||
if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then
|
||||
wait
|
||||
fi
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush" "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -ObjC -l"JCore" -l"JPush" -l"c++" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreTelephony" -framework "Foundation" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "UMAPM" -framework "UMCommon" -framework "UMDevice" -framework "WebKit" -framework "WechatOpenSDK" -weak_framework "Network" -weak_framework "UserNotifications"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "-F${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "-F${PODS_CONFIGURATION_BUILD_DIR}/JCore" "-F${PODS_CONFIGURATION_BUILD_DIR}/JPush" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMAPM" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMCommon" "-F${PODS_CONFIGURATION_BUILD_DIR}/UMDevice" "-F${PODS_CONFIGURATION_BUILD_DIR}/WechatOpenSDK-XCFramework"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
|
||||
PODS_ROOT = ${SRCROOT}/Pods
|
||||
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
|
||||
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
|
||||
VALID_ARCHS = arm64 x86_64
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
ARCHS = $(ARCHS_STANDARD)
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO
|
||||
EXCLUDED_ARCHS[sdk=iphonesimulator*] = arm64
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView" "${PODS_ROOT}/JCore" "${PODS_ROOT}/JPush" "${PODS_ROOT}/UMAPM/UMAPM_2.0.7" "${PODS_ROOT}/UMCommon/UMCommon_7.5.11" "${PODS_ROOT}/UMDevice/UMDevice_3.6.0" "${PODS_ROOT}/WechatOpenSDK-XCFramework" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMAPM" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMCommon" "${PODS_XCFRAMEWORKS_BUILD_DIR}/UMDevice" "${PODS_XCFRAMEWORKS_BUILD_DIR}/WechatOpenSDK-XCFramework"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardCore/IQKeyboardCore.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardNotification/IQKeyboardNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardReturnManager/IQKeyboardReturnManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbar/IQKeyboardToolbar.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQKeyboardToolbarManager/IQKeyboardToolbarManager.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextInputViewNotification/IQTextInputViewNotification.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/IQTextView/IQTextView.framework/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JCore/Headers" "${PODS_XCFRAMEWORKS_BUILD_DIR}/JPush/Headers"
|
||||
OTHER_LDFLAGS = $(inherited) -l"c++" -l"resolv" -l"sqlite3" -l"sqlite3.0" -l"z" -framework "CFNetwork" -framework "Combine" -framework "CoreFoundation" -framework "CoreGraphics" -framework "CoreTelephony" -framework "Foundation" -framework "IQKeyboardCore" -framework "IQKeyboardManagerSwift" -framework "IQKeyboardNotification" -framework "IQKeyboardReturnManager" -framework "IQKeyboardToolbar" -framework "IQKeyboardToolbarManager" -framework "IQTextInputViewNotification" -framework "IQTextView" -framework "Security" -framework "SystemConfiguration" -framework "UIKit" -framework "WebKit" -weak_framework "Network" -weak_framework "UserNotifications"
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
|
||||
PODS_ROOT = ${SRCROOT}/Pods
|
||||
PODS_XCFRAMEWORKS_BUILD_DIR = $(PODS_CONFIGURATION_BUILD_DIR)/XCFrameworkIntermediates
|
||||
USE_RECURSIVE_SCRIPT_INPUTS_IN_SCRIPT_PHASES = YES
|
||||
VALID_ARCHS = arm64 x86_64
|
||||
@@ -0,0 +1,45 @@
|
||||
# 注销账号页 Design QA
|
||||
|
||||
## 对比目标
|
||||
|
||||
- source visual truth path: `/Users/hxindemacmini/.codex/generated_images/01a01eb7-eda7-7f73-ae64-60c5195c974a/exec-1ece6a7c-2a8f-4d8e-a33f-aa5a4fc11c80.png`
|
||||
- implementation screenshot path: `/Users/hxindemacmini/.codex/visualizations/2026/08/20/01a01eb7-eda7-7f73-ae64-60c5195c974a/account-deletion-implementation-v2.png`
|
||||
- full-view comparison path: `/Users/hxindemacmini/.codex/visualizations/2026/08/20/01a01eb7-eda7-7f73-ae64-60c5195c974a/account-deletion-comparison-v2.png`
|
||||
- viewport: iPhone 17 Pro 模拟器,402 × 874 pt,竖屏,浅色模式
|
||||
- source pixels: 852 × 1846,按 @2x 归一化为约 426 × 923 pt
|
||||
- implementation pixels: 1206 × 2622,@3x,对应 402 × 874 pt
|
||||
- density normalization: 对比图将实现截图归一化到 852 × 1846,与设计图等像素并排比较
|
||||
- state: 资产核验加载完成、确认框未勾选、主按钮可点击
|
||||
|
||||
## Full-view comparison evidence
|
||||
|
||||
信息层级、红色风险提示、四项资产卡片、资产明细入口、注销后果卡片、确认框和蓝色主按钮均与设计方向一致。实现保留 iOS 原生状态栏和安全区;设计图没有状态栏且画布更高,因此较小设备首屏只展示注销后果卡片的前两项,剩余内容通过原生滚动区域查看,底部确认和主按钮始终可操作。
|
||||
|
||||
## Focused region comparison evidence
|
||||
|
||||
无需额外局部裁图。等尺寸并排图中,标题、资产数字、分割线、风险色、图标、确认框和主按钮均清晰可读,已经足以核对字体、间距、颜色、图标和文案。
|
||||
|
||||
## Findings
|
||||
|
||||
- 无可执行的 P0、P1 或 P2 差异。
|
||||
- 字体与排版:使用系统中文字体;标题、资产名、资产值和说明文本的层级、字重与设计一致,无截断。
|
||||
- 间距与布局:16pt 页面边距、12pt 圆角、资产行节奏和卡片间距一致;原生状态栏及较短屏幕导致后果列表需要滚动,属于平台和视口差异。
|
||||
- 颜色与视觉令牌:使用项目现有的页面背景、主色、危险色和边框令牌,语义与设计一致。
|
||||
- 图片与图标:页面没有照片类素材;风险和后果图标使用 SF Symbols,清晰且风格统一。
|
||||
- 文案与内容:资产名称、数值、注销后果和7天恢复提示与确认稿一致;没有“可放弃”标签。
|
||||
|
||||
## Comparison history
|
||||
|
||||
1. 第一轮证据:`account-deletion-comparison-v1.png`。发现 [P2] 设计图在未勾选状态仍显示蓝色主按钮,而实现显示灰色禁用按钮。
|
||||
2. 修正:将主按钮的视觉可点击状态与业务确认校验分离。资产加载完成后按钮保持蓝色;未勾选时点击会提示先确认,不能进入验证页。
|
||||
3. 第二轮证据:`account-deletion-comparison-v2.png`。按钮状态已与设计一致,勾选保护由单元测试验证,无新增 P0/P1/P2 差异。
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
- [x] 保持资产数据中性展示,不出现“可放弃”标签
|
||||
- [x] 保持未勾选时蓝色主按钮视觉
|
||||
- [x] 未勾选时阻止进入验证流程并提示用户
|
||||
- [x] 小屏幕使用滚动区域,底部确认与主操作常驻安全区
|
||||
- [x] 保留原生 iOS 状态栏、导航栏和动态岛安全区
|
||||
|
||||
final result: passed
|
||||
+22
-2
@@ -1,8 +1,8 @@
|
||||
# 相册管理与 AI 修图功能说明
|
||||
|
||||
> 文档日期:2026-07-29
|
||||
> 文档日期:2026-08-20
|
||||
> 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图
|
||||
> 数据说明:相册和照片列表、有线相机连接及照片传输继续使用项目已有真实数据能力;传输模式选择保存在本地。原图精修结果、氛围感结果、封面模板、剩余 9 张额度及状态角标目前为本地可演示数据,尚未接入服务端持久化,App 进程重启后会清空。
|
||||
> 数据说明:相册和照片列表、有线相机连接及原图上传继续使用项目已有真实数据能力;传输模式与相册自动修图模板保存在本地。自动修图目前使用本地图像效果演示,修图结果图和任务状态可随 OTG 相册恢复;手动原图精修、氛围感、封面模板和剩余 9 张额度仍为本地演示数据。
|
||||
|
||||
## 1. 照片预览、左右滑动与底部操作
|
||||
|
||||
@@ -51,6 +51,21 @@
|
||||
|
||||
本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。
|
||||
|
||||
### 自动 AI 修图
|
||||
|
||||
新建相册时在价格字段下方固定展示“不修图”和“AI 修图”两个选项,默认选中“不修图”。点击“AI 修图”后再从底部弹出模板选择;每个模板同时展示本地演示照片套用该效果后的缩略图和效果说明。未选模板时不会开启 AI 修图。相册创建成功后,配置按真实相册 ID 保存,上传页仅回显当前一级方式“不修图 / AI 修图”。
|
||||
|
||||
有线传输页顶部的设置项也仅回显“不修图 / AI 修图”。点击后底部弹窗的第一层始终只有这两个选项;选择“AI 修图”后进入带效果缩略图的模板列表,可返回第一层重新选择。新配置只影响尚未开始的上传任务。每张照片仍先按原流程上传原图,得到服务端素材 ID 后才执行自动修图:
|
||||
|
||||
| 阶段 | 列表展示 | 统计归类 | 失败处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 原图上传 | 蓝色“上传中”及进度 | 全部 | 进入上传失败,可重传 |
|
||||
| 自动修图 | 蓝色“修图中” | 全部 | 不回退原图上传结果 |
|
||||
| 修图成功 | 绿色“已上传”,缩略图右上角显示紫色“修” | 已上传 | 使用修图后结果预览 |
|
||||
| 修图失败 | 红色“修图失败” | 已上传 | 更多菜单选择“重新修图”,不重复上传原图 |
|
||||
|
||||
修图中退出 App 时,再次进入会恢复为“修图失败”并允许重试,避免任务长期停留在处理中。
|
||||
|
||||
进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。
|
||||
|
||||
### 状态流转图
|
||||
@@ -65,6 +80,11 @@
|
||||
- [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。
|
||||
- [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。
|
||||
- [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。
|
||||
- [ ] 新建相册固定展示“不修图”和“AI 修图”两个选项。
|
||||
- [ ] 选择“AI 修图”后才弹出模板列表,每个模板都能看到差异化效果图。
|
||||
- [ ] 上传页一级弹窗只有两种修图方式,新配置仅影响后续上传。
|
||||
- [ ] 修图中、修图成功“修”角标和修图失败三种状态展示正确。
|
||||
- [ ] 修图失败仍计入“已上传”,点击“重新修图”不重复上传原图。
|
||||
- [ ] 本次选择不会被上一次保存的模式覆盖。
|
||||
- [ ] 取消或关闭弹窗后仍停留在相册管理。
|
||||
- [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A1B2C3D02FFB84950033C305 /* suixinkanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
C69C08A144C2D5D4A448B7D2 /* Pods_suixinkan.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkan.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkanTests.simulatordebug.xcconfig"; path = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.simulatordebug.xcconfig"; sourceTree = "<group>"; };
|
||||
ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan.simulatordebug.xcconfig"; path = "Target Support Files/Pods-suixinkan/Pods-suixinkan.simulatordebug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -111,6 +113,8 @@
|
||||
1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */,
|
||||
83445E7B4CCCF42C6FD481D3 /* Pods-suixinkanTests.debug.xcconfig */,
|
||||
6CB3E1DA4E97AA4961B25733 /* Pods-suixinkanTests.release.xcconfig */,
|
||||
ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */,
|
||||
DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -432,7 +436,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1010402;
|
||||
CURRENT_PROJECT_VERSION = 1020101;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||
@@ -456,7 +460,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.4;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -498,7 +502,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1010402;
|
||||
CURRENT_PROJECT_VERSION = 1020101;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||
@@ -522,7 +526,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1.4;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -728,6 +732,150 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D1A2B3C42FFB84950033C401 /* SimulatorDebug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */;
|
||||
buildSettings = {
|
||||
APS_ENVIRONMENT = development;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1020101;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = suixinkan/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "随心瞰商家版";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "需要访问相机以拍摄任务素材、扫描订单核销二维码,并连接有线相机进行旅拍相册传输";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "需要访问麦克风以在拍摄素材视频时录制声音";
|
||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "需要保存收款二维码、排队打卡点小程序码和云盘图片视频到相册";
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "需要访问相册以选择头像、身份证、银行卡照片和云盘上传素材";
|
||||
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.2.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkan;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphonesimulator;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = 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 = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "suixinkan/suixinkan-Bridging-Header.h";
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = SimulatorDebug;
|
||||
};
|
||||
D1A2B3C52FFB84950033C402 /* SimulatorDebug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphonesimulator;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = SimulatorDebug;
|
||||
};
|
||||
D1A2B3C62FFB84950033C403 /* SimulatorDebug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkanTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphonesimulator;
|
||||
SUPPORTED_PLATFORMS = iphonesimulator;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/suixinkan.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/suixinkan";
|
||||
};
|
||||
name = SimulatorDebug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -735,6 +883,7 @@
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
93FF17DB2FFB84950033C304 /* Debug */,
|
||||
D1A2B3C52FFB84950033C402 /* SimulatorDebug */,
|
||||
93FF17DC2FFB84950033C304 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
@@ -744,6 +893,7 @@
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
93FF17D92FFB84950033C304 /* Debug */,
|
||||
D1A2B3C42FFB84950033C401 /* SimulatorDebug */,
|
||||
93FF17DA2FFB84950033C304 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
@@ -753,6 +903,7 @@
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A1B2C3DC2FFB84950033C305 /* Debug */,
|
||||
D1A2B3C62FFB84950033C403 /* SimulatorDebug */,
|
||||
A1B2C3DA2FFB84950033C305 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "NO"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "SimulatorDebug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "A1B2C3D32FFB84950033C305"
|
||||
BuildableName = "suixinkanTests.xctest"
|
||||
BlueprintName = "suixinkanTests"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "SimulatorDebug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "SimulatorDebug"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "93FF17C42FFB84950033C304"
|
||||
BuildableName = "suixinkan.app"
|
||||
BlueprintName = "suixinkan"
|
||||
ReferencedContainer = "container:suixinkan.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "SimulatorDebug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "payment_method_alipay.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
suixinkan/Assets.xcassets/OfflineCollection/payment_method_alipay.imageset/payment_method_alipay.svg
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
<!-- 来源:阿里巴巴矢量图标库,支付宝支付,图标 ID 1486848 -->
|
||||
<svg width="24" height="24" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1024.0512 701.0304V196.864A196.9664 196.9664 0 0 0 827.136 0H196.864A196.9664 196.9664 0 0 0 0 196.864v630.272A196.9152 196.9152 0 0 0 196.864 1024h630.272a197.12 197.12 0 0 0 193.8432-162.0992c-52.224-22.6304-278.528-120.32-396.4416-176.64-89.7024 108.6976-183.7056 173.9264-325.3248 173.9264s-236.1856-87.2448-224.8192-194.048c7.4752-70.0416 55.552-184.576 264.2944-164.9664 110.08 10.3424 160.4096 30.8736 250.1632 60.5184 23.1936-42.5984 42.496-89.4464 57.1392-139.264H248.064v-39.424h196.9152V311.1424H204.8V267.776h240.128V165.632s2.1504-15.9744 19.8144-15.9744h98.4576V267.776h256v43.4176h-256V381.952h208.8448a805.9904 805.9904 0 0 1-84.8384 212.6848c60.672 22.016 336.7936 106.3936 336.7936 106.3936zM283.5456 791.6032c-149.6576 0-173.312-94.464-165.376-133.9392 7.8336-39.3216 51.2-90.624 134.4-90.624 95.5904 0 181.248 24.4736 284.0576 74.5472-72.192 94.0032-160.9216 150.016-253.0816 150.016z" fill="#009FE8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "payment_method_cash.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
<!-- 来源:阿里巴巴矢量图标库,现金,图标 ID 25537599 -->
|
||||
<svg width="24" height="24" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M128 256v512h768V256H128z m853.333333-85.333333v682.666666H42.666667V170.666667h938.666666z" fill="#2E2F30"/>
|
||||
<path d="M512 682.666667a170.666667 170.666667 0 1 1 0-341.333334 170.666667 170.666667 0 0 1 0 341.333334z m0-85.333334a85.333333 85.333333 0 1 0 0-170.666666 85.333333 85.333333 0 0 0 0 170.666666z" fill="#2E2F30"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "payment_method_wechat.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
suixinkan/Assets.xcassets/OfflineCollection/payment_method_wechat.imageset/payment_method_wechat.svg
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
<!-- 来源:阿里巴巴矢量图标库,微信支付,图标 ID 781032 -->
|
||||
<svg width="24" height="24" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M404.511405 600.865957c-4.042059 2.043542-8.602935 3.223415-13.447267 3.223415-11.197016 0-20.934798-6.169513-26.045189-15.278985l-1.959631-4.296863-81.56569-178.973184c-0.880043-1.954515-1.430582-4.14746-1.430582-6.285147 0-8.251941 6.686283-14.944364 14.938224-14.944364 3.351328 0 6.441713 1.108241 8.94165 2.966565l96.242971 68.521606c7.037277 4.609994 15.433504 7.305383 24.464181 7.305383 5.40101 0 10.533914-1.00284 15.328104-2.75167l452.645171-201.459315C811.496653 163.274644 677.866167 100.777241 526.648117 100.777241c-247.448742 0-448.035176 167.158091-448.035176 373.361453 0 112.511493 60.353576 213.775828 154.808832 282.214547 7.582699 5.405103 12.537548 14.292518 12.537548 24.325012 0 3.312442-0.712221 6.358825-1.569752 9.515724-7.544837 28.15013-19.62599 73.202209-20.188808 75.314313-0.940418 3.529383-2.416026 7.220449-2.416026 10.917654 0 8.245801 6.692423 14.933107 14.944364 14.933107 3.251044 0 5.89015-1.202385 8.629541-2.7793l98.085946-56.621579c7.377014-4.266164 15.188934-6.89913 23.790846-6.89913 4.577249 0 9.003048 0.703011 13.174044 1.978051 45.75509 13.159718 95.123474 20.476357 146.239666 20.476357 247.438509 0 448.042339-167.162184 448.042339-373.372709 0-62.451354-18.502399-121.275087-51.033303-173.009356L407.778822 598.977957 404.511405 600.865957z" fill="#00C800"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// AccountDeletionModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 注销核验资产,使用中性文案展示当前账号拥有的数据与权益。
|
||||
struct AccountDeletionAssetSummary: Codable, Hashable, Sendable {
|
||||
|
||||
/// 注销核验覆盖的资产类型。
|
||||
enum Kind: String, Codable, CaseIterable, Sendable {
|
||||
case wallet
|
||||
case works
|
||||
case projects
|
||||
case cloudFiles
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
let title: String
|
||||
let valueText: String
|
||||
}
|
||||
|
||||
/// 注销前置核验结果,包含资产快照和注销影响说明。
|
||||
struct AccountDeletionPrecheck: Equatable, Sendable {
|
||||
let assets: [AccountDeletionAssetSummary]
|
||||
let consequences: [String]
|
||||
}
|
||||
|
||||
/// 注销申请当前状态。
|
||||
enum AccountDeletionRequestStatus: String, Codable, Sendable {
|
||||
case pending
|
||||
case canceled
|
||||
case completed
|
||||
}
|
||||
|
||||
/// 注销申请记录,保存7天冷静期及用户确认的资产范围。
|
||||
struct AccountDeletionRequest: Codable, Equatable, Sendable {
|
||||
let id: UUID
|
||||
let clientRequestID: UUID
|
||||
let username: String
|
||||
let submittedAt: Date
|
||||
let scheduledDeletionAt: Date
|
||||
var status: AccountDeletionRequestStatus
|
||||
let acknowledgedAssetKinds: [AccountDeletionAssetSummary.Kind]
|
||||
}
|
||||
|
||||
/// 登录时需要处理的注销状态。
|
||||
enum AccountDeletionLoginState: Equatable, Sendable {
|
||||
case none
|
||||
case pending(AccountDeletionRequest)
|
||||
case completed(AccountDeletionRequest)
|
||||
}
|
||||
|
||||
/// 注销短信验证页所需的稳定上下文。
|
||||
struct AccountDeletionVerificationContext: Equatable, Sendable {
|
||||
let username: String
|
||||
let maskedPhone: String
|
||||
let assets: [AccountDeletionAssetSummary]
|
||||
}
|
||||
|
||||
/// 注销业务错误,统一提供可直接展示的中文提示。
|
||||
enum AccountDeletionError: LocalizedError, Equatable {
|
||||
case missingIdentity
|
||||
case invalidVerificationCode
|
||||
case incompleteAcknowledgement
|
||||
case noPendingRequest
|
||||
case cancellationExpired
|
||||
case deletionCompleted
|
||||
case persistenceFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingIdentity:
|
||||
"当前登录账号缺少手机号,暂时无法提交注销"
|
||||
case .invalidVerificationCode:
|
||||
"验证码错误,请输入演示验证码 123456"
|
||||
case .incompleteAcknowledgement:
|
||||
"请先确认已了解全部资产和注销后果"
|
||||
case .noPendingRequest:
|
||||
"当前账号没有待取消的注销申请"
|
||||
case .cancellationExpired:
|
||||
"7天取消期限已过,无法恢复账号"
|
||||
case .deletionCompleted:
|
||||
"账号已完成注销,无法继续登录"
|
||||
case .persistenceFailed:
|
||||
"注销状态保存失败,请稍后重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销账号身份工具,统一手机号规范化、掩码和当前登录身份解析。
|
||||
enum AccountDeletionIdentity {
|
||||
|
||||
/// 从当前会话读取登录手机号,优先使用登录页保存的原始账号。
|
||||
static func currentUsername(session: AppSessionStore = AppStore.shared.session) -> String? {
|
||||
let candidates = [session.lastLoginUsername, session.phone]
|
||||
return candidates
|
||||
.compactMap { $0 }
|
||||
.map(normalizedUsername)
|
||||
.first { !$0.isEmpty }
|
||||
}
|
||||
|
||||
/// 将手机号账号规范化为纯数字,并兼容 +86 前缀。
|
||||
static func normalizedUsername(_ value: String) -> String {
|
||||
var digits = value.filter(\.isNumber)
|
||||
if digits.hasPrefix("86"), digits.count == 13 {
|
||||
digits.removeFirst(2)
|
||||
}
|
||||
return digits
|
||||
}
|
||||
|
||||
/// 将手机号转换为用于页面展示的脱敏格式。
|
||||
static func maskedPhone(_ value: String) -> String {
|
||||
let username = normalizedUsername(value)
|
||||
guard username.count == 11 else { return username }
|
||||
let start = username.prefix(3)
|
||||
let end = username.suffix(4)
|
||||
return "\(start)****\(end)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销时间格式化工具,统一成功页和登录恢复弹窗的中文时间。
|
||||
enum AccountDeletionDateFormatter {
|
||||
|
||||
/// 将服务端计划删除时间格式化为中文日期时间。
|
||||
static func displayText(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||
formatter.dateFormat = "yyyy年M月d日 HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//
|
||||
// AccountDeletionMockService.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 注销服务协议,隔离前置核验、短信验证、提交和取消能力。
|
||||
protocol AccountDeletionServing: AnyObject {
|
||||
/// 加载整个登录账号的注销资产快照。
|
||||
func loadPrecheck(username: String) async throws -> AccountDeletionPrecheck
|
||||
|
||||
/// 向当前绑定手机号发送注销验证码。
|
||||
func sendVerificationCode(to username: String) async throws
|
||||
|
||||
/// 提交注销申请并开始7天冷静期。
|
||||
func submitDeletion(
|
||||
username: String,
|
||||
verificationCode: String,
|
||||
acknowledgedAssetKinds: Set<AccountDeletionAssetSummary.Kind>,
|
||||
clientRequestID: UUID
|
||||
) async throws -> AccountDeletionRequest
|
||||
|
||||
/// 查询指定登录账号在登录时应处理的注销状态。
|
||||
func loginState(for username: String) -> AccountDeletionLoginState
|
||||
|
||||
/// 在7天冷静期内取消注销申请。
|
||||
func cancelDeletion(username: String) throws -> AccountDeletionRequest
|
||||
}
|
||||
|
||||
/// 注销功能 Mock 服务,使用 UserDefaults 完成可重复演示的7天注销闭环。
|
||||
final class AccountDeletionMockService: AccountDeletionServing {
|
||||
|
||||
/// 应用内共享的注销 Mock 服务。
|
||||
static let shared = AccountDeletionMockService()
|
||||
|
||||
/// Mock 短信验证码。
|
||||
static let verificationCode = "123456"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let now: () -> Date
|
||||
private let responseDelayNanoseconds: UInt64
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private let keyPrefix = "account_deletion_mock_v1_"
|
||||
|
||||
/// 创建注销 Mock 服务,测试可注入独立存储、时间和响应延迟。
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
responseDelayNanoseconds: UInt64 = 180_000_000
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.now = now
|
||||
self.responseDelayNanoseconds = responseDelayNanoseconds
|
||||
}
|
||||
|
||||
func loadPrecheck(username: String) async throws -> AccountDeletionPrecheck {
|
||||
try await simulateDelay()
|
||||
_ = try validateUsername(username)
|
||||
return AccountDeletionPrecheck(
|
||||
assets: [
|
||||
AccountDeletionAssetSummary(kind: .wallet, title: "钱包余额", valueText: "¥286.50"),
|
||||
AccountDeletionAssetSummary(kind: .works, title: "作品与相册", valueText: "36个"),
|
||||
AccountDeletionAssetSummary(kind: .projects, title: "项目", valueText: "4个"),
|
||||
AccountDeletionAssetSummary(kind: .cloudFiles, title: "云盘文件", valueText: "8.6 GB"),
|
||||
],
|
||||
consequences: [
|
||||
"所有景区与门店账号将解除",
|
||||
"个人作品和云盘文件将删除",
|
||||
"提交后7天内再次登录可取消注销",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func sendVerificationCode(to username: String) async throws {
|
||||
try await simulateDelay()
|
||||
_ = try validateUsername(username)
|
||||
}
|
||||
|
||||
func submitDeletion(
|
||||
username: String,
|
||||
verificationCode: String,
|
||||
acknowledgedAssetKinds: Set<AccountDeletionAssetSummary.Kind>,
|
||||
clientRequestID: UUID
|
||||
) async throws -> AccountDeletionRequest {
|
||||
try await simulateDelay()
|
||||
let normalized = try validateUsername(username)
|
||||
guard verificationCode == Self.verificationCode else {
|
||||
throw AccountDeletionError.invalidVerificationCode
|
||||
}
|
||||
guard acknowledgedAssetKinds == Set(AccountDeletionAssetSummary.Kind.allCases) else {
|
||||
throw AccountDeletionError.incompleteAcknowledgement
|
||||
}
|
||||
|
||||
if let existing = storedRequest(for: normalized), existing.status == .pending {
|
||||
return existing
|
||||
}
|
||||
if let existing = storedRequest(for: normalized), existing.status == .completed {
|
||||
throw AccountDeletionError.deletionCompleted
|
||||
}
|
||||
|
||||
let submittedAt = now()
|
||||
guard let scheduledDeletionAt = Calendar(identifier: .gregorian)
|
||||
.date(byAdding: .day, value: 7, to: submittedAt) else {
|
||||
throw AccountDeletionError.persistenceFailed
|
||||
}
|
||||
let request = AccountDeletionRequest(
|
||||
id: UUID(),
|
||||
clientRequestID: clientRequestID,
|
||||
username: normalized,
|
||||
submittedAt: submittedAt,
|
||||
scheduledDeletionAt: scheduledDeletionAt,
|
||||
status: .pending,
|
||||
acknowledgedAssetKinds: AccountDeletionAssetSummary.Kind.allCases
|
||||
)
|
||||
try save(request)
|
||||
return request
|
||||
}
|
||||
|
||||
func loginState(for username: String) -> AccountDeletionLoginState {
|
||||
let normalized = AccountDeletionIdentity.normalizedUsername(username)
|
||||
guard var request = storedRequest(for: normalized) else { return .none }
|
||||
|
||||
switch request.status {
|
||||
case .canceled:
|
||||
return .none
|
||||
case .completed:
|
||||
return .completed(request)
|
||||
case .pending:
|
||||
guard now() < request.scheduledDeletionAt else {
|
||||
request.status = .completed
|
||||
try? save(request)
|
||||
return .completed(request)
|
||||
}
|
||||
return .pending(request)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelDeletion(username: String) throws -> AccountDeletionRequest {
|
||||
let normalized = try validateUsername(username)
|
||||
guard var request = storedRequest(for: normalized) else {
|
||||
throw AccountDeletionError.noPendingRequest
|
||||
}
|
||||
guard request.status == .pending else {
|
||||
if request.status == .completed {
|
||||
throw AccountDeletionError.cancellationExpired
|
||||
}
|
||||
throw AccountDeletionError.noPendingRequest
|
||||
}
|
||||
guard now() < request.scheduledDeletionAt else {
|
||||
request.status = .completed
|
||||
try save(request)
|
||||
throw AccountDeletionError.cancellationExpired
|
||||
}
|
||||
|
||||
request.status = .canceled
|
||||
try save(request)
|
||||
return request
|
||||
}
|
||||
|
||||
/// 删除指定账号的 Mock 注销状态,仅供单元测试与调试使用。
|
||||
func reset(username: String) {
|
||||
let normalized = AccountDeletionIdentity.normalizedUsername(username)
|
||||
defaults.removeObject(forKey: storageKey(username: normalized))
|
||||
}
|
||||
|
||||
private func validateUsername(_ username: String) throws -> String {
|
||||
let normalized = AccountDeletionIdentity.normalizedUsername(username)
|
||||
guard normalized.count == 11, normalized.first == "1" else {
|
||||
throw AccountDeletionError.missingIdentity
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func storedRequest(for username: String) -> AccountDeletionRequest? {
|
||||
guard let data = defaults.data(forKey: storageKey(username: username)) else { return nil }
|
||||
return try? decoder.decode(AccountDeletionRequest.self, from: data)
|
||||
}
|
||||
|
||||
private func save(_ request: AccountDeletionRequest) throws {
|
||||
do {
|
||||
let data = try encoder.encode(request)
|
||||
defaults.set(data, forKey: storageKey(username: request.username))
|
||||
} catch {
|
||||
throw AccountDeletionError.persistenceFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func storageKey(username: String) -> String {
|
||||
keyPrefix + username
|
||||
}
|
||||
|
||||
private func simulateDelay() async throws {
|
||||
guard responseDelayNanoseconds > 0 else { return }
|
||||
try await Task.sleep(nanoseconds: responseDelayNanoseconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//
|
||||
// AccountDeletionViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 注销资产核验页 ViewModel,管理资产快照、确认状态与验证页上下文。
|
||||
final class AccountDeletionViewModel {
|
||||
private let service: any AccountDeletionServing
|
||||
|
||||
let username: String
|
||||
private(set) var assets: [AccountDeletionAssetSummary] = []
|
||||
private(set) var consequences: [String] = []
|
||||
private(set) var isAcknowledged = false
|
||||
private(set) var isLoading = false
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
/// 创建注销核验 ViewModel,默认读取当前登录账号并使用共享 Mock 服务。
|
||||
init(
|
||||
username: String? = AccountDeletionIdentity.currentUsername(),
|
||||
service: any AccountDeletionServing = AccountDeletionMockService.shared
|
||||
) {
|
||||
self.username = AccountDeletionIdentity.normalizedUsername(username ?? "")
|
||||
self.service = service
|
||||
}
|
||||
|
||||
/// 勾选后才允许进入短信验证。
|
||||
var isContinueEnabled: Bool {
|
||||
!assets.isEmpty && isAcknowledged && !isLoading
|
||||
}
|
||||
|
||||
/// 资产加载完成后主按钮保持可点击,未勾选时由业务校验给出明确提示。
|
||||
var isContinueButtonEnabled: Bool {
|
||||
!assets.isEmpty && !isLoading
|
||||
}
|
||||
|
||||
/// 拉取注销前置核验结果。
|
||||
func load() async {
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let precheck = try await service.loadPrecheck(username: username)
|
||||
assets = precheck.assets
|
||||
consequences = precheck.consequences
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换用户对注销影响的确认状态。
|
||||
func toggleAcknowledgement() {
|
||||
isAcknowledged.toggle()
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 生成短信验证页上下文。
|
||||
func makeVerificationContext() throws -> AccountDeletionVerificationContext {
|
||||
guard isContinueEnabled else {
|
||||
throw AccountDeletionError.incompleteAcknowledgement
|
||||
}
|
||||
guard !username.isEmpty else {
|
||||
throw AccountDeletionError.missingIdentity
|
||||
}
|
||||
return AccountDeletionVerificationContext(
|
||||
username: username,
|
||||
maskedPhone: AccountDeletionIdentity.maskedPhone(username),
|
||||
assets: assets
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用当前服务创建短信验证 ViewModel,保持同一套 Mock 状态。
|
||||
func makeVerificationViewModel() throws -> AccountDeletionVerificationViewModel {
|
||||
try AccountDeletionVerificationViewModel(
|
||||
context: makeVerificationContext(),
|
||||
service: service
|
||||
)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销短信验证页 ViewModel,负责验证码发送、校验和提交申请。
|
||||
final class AccountDeletionVerificationViewModel {
|
||||
private let service: any AccountDeletionServing
|
||||
private let clientRequestID = UUID()
|
||||
|
||||
let context: AccountDeletionVerificationContext
|
||||
private(set) var verificationCode = ""
|
||||
private(set) var isSendingCode = false
|
||||
private(set) var isSubmitting = false
|
||||
private(set) var hasSentCode = false
|
||||
private(set) var errorMessage: String?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
/// 创建短信验证 ViewModel。
|
||||
init(
|
||||
context: AccountDeletionVerificationContext,
|
||||
service: any AccountDeletionServing = AccountDeletionMockService.shared
|
||||
) throws {
|
||||
guard !context.username.isEmpty else {
|
||||
throw AccountDeletionError.missingIdentity
|
||||
}
|
||||
self.context = context
|
||||
self.service = service
|
||||
}
|
||||
|
||||
/// 验证码满足6位数字且当前未提交时允许继续。
|
||||
var isSubmitEnabled: Bool {
|
||||
verificationCode.count == 6
|
||||
&& verificationCode.allSatisfy(\.isNumber)
|
||||
&& !isSubmitting
|
||||
}
|
||||
|
||||
/// 更新用户输入的验证码,只保留前6位数字。
|
||||
func updateVerificationCode(_ value: String) {
|
||||
verificationCode = String(value.filter(\.isNumber).prefix(6))
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 发送 Mock 短信验证码。
|
||||
func sendVerificationCode() async {
|
||||
guard !isSendingCode else { return }
|
||||
isSendingCode = true
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSendingCode = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
try await service.sendVerificationCode(to: context.username)
|
||||
hasSentCode = true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交注销申请并返回计划删除时间。
|
||||
func submit() async throws -> AccountDeletionRequest {
|
||||
guard isSubmitEnabled else {
|
||||
throw AccountDeletionError.invalidVerificationCode
|
||||
}
|
||||
guard !isSubmitting else { throw CancellationError() }
|
||||
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSubmitting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
return try await service.submitDeletion(
|
||||
username: context.username,
|
||||
verificationCode: verificationCode,
|
||||
acknowledgedAssetKinds: Set(context.assets.map(\.kind)),
|
||||
clientRequestID: clientRequestID
|
||||
)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@@ -70,10 +70,10 @@ enum AuthSessionHelper {
|
||||
AppStore.shared.session.userId = String(user.businessUserId)
|
||||
AppStore.shared.session.accountType = .scenicUser
|
||||
AppStore.shared.session.accountDisplayName = firstNonEmpty(
|
||||
user.scenicName, user.nickname, user.realName, user.username
|
||||
user.scenicName, user.nickname, user.realName
|
||||
)
|
||||
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName)
|
||||
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username)
|
||||
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.realName)
|
||||
AppStore.shared.session.realName = user.realName
|
||||
AppStore.shared.session.phone = user.phone
|
||||
AppStore.shared.session.currentScenicId = user.scenicId
|
||||
AppStore.shared.session.currentScenicName = user.scenicName
|
||||
@@ -90,10 +90,10 @@ enum AuthSessionHelper {
|
||||
AppStore.shared.session.userId = String(user.businessUserId)
|
||||
AppStore.shared.session.accountType = .storeUser
|
||||
AppStore.shared.session.accountDisplayName = firstNonEmpty(
|
||||
user.storeName, user.scenicName, user.realName, user.userName, user.username
|
||||
user.storeName, user.scenicName, user.realName
|
||||
)
|
||||
AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName)
|
||||
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username)
|
||||
AppStore.shared.session.userName = user.realName
|
||||
AppStore.shared.session.realName = user.realName
|
||||
AppStore.shared.session.phone = user.phone
|
||||
AppStore.shared.session.avatar = user.avatar
|
||||
AppStore.shared.session.currentScenicId = user.scenicId
|
||||
|
||||
@@ -53,10 +53,11 @@ enum LoginValidationError: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。
|
||||
/// 登录结果实体,区分正常登录、账号选择和注销冷静期恢复三种情况。
|
||||
enum LoginResolution {
|
||||
case completed(V9AuthResponse, AccountSwitchAccount)
|
||||
case needsAccountSelection(AccountSelectionPayload)
|
||||
case accountDeletionPending(V9AuthResponse, AccountDeletionRequest)
|
||||
}
|
||||
|
||||
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
|
||||
@@ -196,7 +197,6 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
let userId: Int
|
||||
let scenicUserId: Int
|
||||
let ssUserId: Int
|
||||
let username: String
|
||||
let realName: String
|
||||
let nickname: String
|
||||
let phone: String
|
||||
@@ -212,7 +212,7 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username
|
||||
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
|
||||
}
|
||||
|
||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||
@@ -238,8 +238,6 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
case userId = "user_id"
|
||||
case scenicUserId = "scenic_user_id"
|
||||
case ssUserId = "ss_user_id"
|
||||
case username = "user_name"
|
||||
case legacyUsername = "username"
|
||||
case realName = "real_name"
|
||||
case nickname
|
||||
case phone
|
||||
@@ -258,9 +256,6 @@ struct V9ScenicUser: Decodable, Equatable {
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
|
||||
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
|
||||
let preferredUsername = try container.decodeLossyString(forKey: .username)
|
||||
let legacyUsername = try container.decodeLossyString(forKey: .legacyUsername)
|
||||
username = preferredUsername.nonEmpty ?? legacyUsername
|
||||
realName = try container.decodeLossyString(forKey: .realName)
|
||||
nickname = try container.decodeLossyString(forKey: .nickname)
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
@@ -281,8 +276,6 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
let id: Int
|
||||
let userId: Int
|
||||
let storeUserId: Int
|
||||
let username: String
|
||||
let userName: String
|
||||
let realName: String
|
||||
let phone: String
|
||||
let avatar: String
|
||||
@@ -300,14 +293,14 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username
|
||||
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
|
||||
}
|
||||
|
||||
func toAccountSwitchAccount() -> AccountSwitchAccount {
|
||||
AccountSwitchAccount(
|
||||
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
|
||||
businessUserId: businessUserId,
|
||||
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号",
|
||||
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
|
||||
subtitle: joinAccountSubtitle(scenicName, roleName),
|
||||
phone: phone,
|
||||
realName: realName,
|
||||
@@ -325,8 +318,6 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
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
|
||||
@@ -346,8 +337,6 @@ struct V9StoreUser: Decodable, Equatable {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// CommissionRateLogViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 获客员分成比例修改记录 ViewModel,负责刷新、分页与重复项合并。
|
||||
final class CommissionRateLogViewModel {
|
||||
private enum Constants {
|
||||
static let pageSize = 10
|
||||
}
|
||||
|
||||
private(set) var items: [CommissionRateLogEntity] = []
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isLoadingMore = false
|
||||
private(set) var initialLoading = true
|
||||
private(set) var canLoadMore = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private let saleUserId: Int
|
||||
private var total = 0
|
||||
private var lastLoadedPage = 0
|
||||
private var isLoading = false
|
||||
|
||||
init(saleUserId: Int) {
|
||||
self.saleUserId = saleUserId
|
||||
}
|
||||
|
||||
/// 重新加载第一页修改记录。
|
||||
func refresh(api: OrderAPI) async {
|
||||
await load(api: api, page: 1, append: false)
|
||||
}
|
||||
|
||||
/// 在存在下一页时加载更多修改记录。
|
||||
func loadMore(api: OrderAPI) async {
|
||||
guard canLoadMore, !isRefreshing, !isLoadingMore else { return }
|
||||
await load(api: api, page: lastLoadedPage + 1, append: true)
|
||||
}
|
||||
|
||||
private func load(api: OrderAPI, page: Int, append: Bool) async {
|
||||
guard saleUserId > 0 else {
|
||||
initialLoading = false
|
||||
onShowMessage?("获客员信息无效")
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
if append {
|
||||
isLoadingMore = true
|
||||
} else {
|
||||
isRefreshing = true
|
||||
}
|
||||
notifyStateChange()
|
||||
|
||||
defer {
|
||||
isLoading = false
|
||||
initialLoading = false
|
||||
if append {
|
||||
isLoadingMore = false
|
||||
} else {
|
||||
isRefreshing = false
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.saleUserCommissionRateLogs(
|
||||
saleUserId: saleUserId,
|
||||
page: page,
|
||||
pageSize: Constants.pageSize
|
||||
)
|
||||
if append {
|
||||
let existingIDs = Set(items.map(\.id))
|
||||
items.append(contentsOf: response.list.filter { !existingIDs.contains($0.id) })
|
||||
} else {
|
||||
var seenIDs = Set<Int>()
|
||||
items = response.list.filter { seenIDs.insert($0.id).inserted }
|
||||
}
|
||||
total = max(0, response.total)
|
||||
lastLoadedPage = page
|
||||
canLoadMore = items.count < total && !response.list.isEmpty
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@@ -16,21 +16,24 @@ enum HomeMenuIconFactory {
|
||||
|
||||
private static let imageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
/// 优先加载 Assets 资源图;否则加载 SF Symbol,并统一渲染到固定画布。
|
||||
/// 优先加载 Assets 资源图并归一化画布;SF Symbol 保留系统符号表示与分层渲染。
|
||||
static func image(named iconName: String) -> UIImage? {
|
||||
let cacheKey = iconName as NSString
|
||||
if let cachedImage = imageCache.object(forKey: cacheKey) {
|
||||
return cachedImage
|
||||
}
|
||||
|
||||
guard let sourceImage = UIImage(named: iconName)
|
||||
?? UIImage(systemName: iconName, withConfiguration: symbolConfiguration) else {
|
||||
return nil
|
||||
let image: UIImage?
|
||||
if let assetImage = UIImage(named: iconName) {
|
||||
image = renderOnFixedCanvas(assetImage)
|
||||
} else {
|
||||
image = UIImage(systemName: iconName, withConfiguration: symbolConfiguration)
|
||||
}
|
||||
|
||||
let normalizedImage = renderOnFixedCanvas(sourceImage)
|
||||
imageCache.setObject(normalizedImage, forKey: cacheKey)
|
||||
return normalizedImage
|
||||
if let image {
|
||||
imageCache.setObject(image, forKey: cacheKey)
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
private static func renderOnFixedCanvas(_ image: UIImage) -> UIImage {
|
||||
|
||||
@@ -20,6 +20,7 @@ enum AMapBootstrap {
|
||||
defer { lock.unlock() }
|
||||
guard !isConfigured else { return true }
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
|
||||
AMapLocationManager.updatePrivacyAgree(.didAgree)
|
||||
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
|
||||
@@ -29,6 +30,7 @@ enum AMapBootstrap {
|
||||
|
||||
AMapServices.shared().apiKey = AMapConfig.apiKey
|
||||
AMapServices.shared().enableHTTPS = true
|
||||
#endif
|
||||
isConfigured = true
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -3,16 +3,28 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// 高德逆地理编码封装,对齐 Android `GeocodeSearch`。
|
||||
/// 地址逆地理编码能力,供平台定位实现注入。
|
||||
@MainActor
|
||||
final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
protocol LocationAddressGeocoding: AnyObject {
|
||||
/// 将坐标解析为可读地址,失败时返回空字符串。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String
|
||||
}
|
||||
|
||||
/// 平台逆地理编码封装;真机使用高德,模拟器使用 Core Location。
|
||||
@MainActor
|
||||
final class LocationGeocoder: NSObject, LocationAddressGeocoding {
|
||||
|
||||
nonisolated static let shared = LocationGeocoder()
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
private let geocoder = CLGeocoder()
|
||||
#else
|
||||
private var searchAPI: AMapSearchAPI?
|
||||
private var continuation: CheckedContinuation<String, Never>?
|
||||
#endif
|
||||
|
||||
nonisolated private override init() {
|
||||
super.init()
|
||||
@@ -20,6 +32,26 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
|
||||
/// 将坐标解析为可读地址。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
#if targetEnvironment(simulator)
|
||||
let location = CLLocation(latitude: latitude, longitude: longitude)
|
||||
guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else {
|
||||
return ""
|
||||
}
|
||||
if let name = placemark.name?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty {
|
||||
return name
|
||||
}
|
||||
return [
|
||||
placemark.administrativeArea,
|
||||
placemark.locality,
|
||||
placemark.subLocality,
|
||||
placemark.thoroughfare,
|
||||
placemark.subThoroughfare,
|
||||
]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined()
|
||||
#else
|
||||
do {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
} catch {
|
||||
@@ -40,8 +72,10 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
request.radius = 200
|
||||
api.aMapReGoecodeSearch(request)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
|
||||
let address = response?.regeocode?.formattedAddress ?? ""
|
||||
continuation?.resume(returning: address)
|
||||
@@ -62,4 +96,9 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
|
||||
searchAPI = api
|
||||
return api
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !targetEnvironment(simulator)
|
||||
extension LocationGeocoder: AMapSearchDelegate {}
|
||||
#endif
|
||||
|
||||
@@ -6,7 +6,165 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
/// 统一定位服务,封装高德 `AMapLocationManager`,对齐 Android `LocationProvider`。
|
||||
/// Core Location 管理器最小接口,便于模拟器定位实现注入测试替身。
|
||||
@MainActor
|
||||
protocol CoreLocationManaging: AnyObject {
|
||||
var authorizationStatus: CLAuthorizationStatus { get }
|
||||
var desiredAccuracy: CLLocationAccuracy { get set }
|
||||
var delegate: CLLocationManagerDelegate? { get set }
|
||||
|
||||
/// 请求使用期间定位权限。
|
||||
func requestWhenInUseAuthorization()
|
||||
/// 请求一次当前位置。
|
||||
func requestLocation()
|
||||
}
|
||||
|
||||
extension CLLocationManager: CoreLocationManaging {}
|
||||
|
||||
/// Core Location 定位实现,供模拟器运行并支持依赖注入测试。
|
||||
@MainActor
|
||||
final class CoreLocationProvider: NSObject, LocationProviding, CLLocationManagerDelegate {
|
||||
|
||||
nonisolated static let shared = CoreLocationProvider()
|
||||
|
||||
private var appStore: AppStore = .shared
|
||||
private var manager: any CoreLocationManaging = CLLocationManager()
|
||||
private var geocoder: any LocationAddressGeocoding = LocationGeocoder.shared
|
||||
private var permissionContinuation: CheckedContinuation<Void, Error>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocationCoordinate2D, Error>?
|
||||
|
||||
nonisolated override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 使用可控依赖创建 Core Location 定位服务。
|
||||
init(
|
||||
appStore: AppStore,
|
||||
manager: any CoreLocationManaging,
|
||||
geocoder: any LocationAddressGeocoding
|
||||
) {
|
||||
self.appStore = appStore
|
||||
self.manager = manager
|
||||
self.geocoder = geocoder
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
}
|
||||
|
||||
/// 请求坐标及逆地理编码地址。
|
||||
func requestSnapshot(
|
||||
desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
|
||||
) async throws -> HomeLocationSnapshot {
|
||||
let coordinate = try await requestCoordinate(desiredAccuracy: desiredAccuracy)
|
||||
let address = await geocoder.reverseGeocode(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude
|
||||
)
|
||||
return HomeLocationSnapshot(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
address: address
|
||||
)
|
||||
}
|
||||
|
||||
/// 请求一次当前位置坐标。
|
||||
func requestCoordinate(
|
||||
desiredAccuracy: CLLocationAccuracy
|
||||
) async throws -> CLLocationCoordinate2D {
|
||||
try requirePrivacyAgreement()
|
||||
try await ensureLocationPermission()
|
||||
guard locationContinuation == nil else {
|
||||
throw LocationProviderError.locationFailed
|
||||
}
|
||||
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = desiredAccuracy
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
locationContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用系统服务将坐标解析为地址。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
guard appStore.session.privacyAgreementAccepted else { return "" }
|
||||
return await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
|
||||
/// 请求系统定位权限,等待用户完成首次授权。
|
||||
func ensureLocationPermission() async throws {
|
||||
manager.delegate = self
|
||||
switch manager.authorizationStatus {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return
|
||||
case .denied, .restricted:
|
||||
throw LocationProviderError.permissionDenied
|
||||
case .notDetermined:
|
||||
guard permissionContinuation == nil else {
|
||||
throw LocationProviderError.locationFailed
|
||||
}
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
permissionContinuation = continuation
|
||||
manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
@unknown default:
|
||||
throw LocationProviderError.permissionDenied
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
resolvePermission(status: self.manager.authorizationStatus)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let continuation = locationContinuation else { return }
|
||||
locationContinuation = nil
|
||||
guard let coordinate = locations.last?.coordinate,
|
||||
CLLocationCoordinate2DIsValid(coordinate),
|
||||
coordinate.latitude != 0 || coordinate.longitude != 0 else {
|
||||
continuation.resume(throwing: LocationProviderError.locationFailed)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: coordinate)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
guard let continuation = locationContinuation else { return }
|
||||
locationContinuation = nil
|
||||
continuation.resume(throwing: LocationProviderError.locationFailed)
|
||||
}
|
||||
|
||||
private func requirePrivacyAgreement() throws {
|
||||
guard appStore.session.privacyAgreementAccepted else {
|
||||
throw LocationProviderError.privacyNotAccepted
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvePermission(status: CLAuthorizationStatus) {
|
||||
guard let continuation = permissionContinuation else { return }
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
permissionContinuation = nil
|
||||
continuation.resume()
|
||||
case .denied, .restricted:
|
||||
permissionContinuation = nil
|
||||
continuation.resume(throwing: LocationProviderError.permissionDenied)
|
||||
case .notDetermined:
|
||||
break
|
||||
@unknown default:
|
||||
permissionContinuation = nil
|
||||
continuation.resume(throwing: LocationProviderError.permissionDenied)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
|
||||
/// 模拟器默认定位服务,使用 Core Location 与系统逆地理编码。
|
||||
typealias LocationProvider = CoreLocationProvider
|
||||
|
||||
#else
|
||||
|
||||
/// 真机统一定位服务,封装高德 `AMapLocationManager`,对齐 Android `LocationProvider`。
|
||||
@MainActor
|
||||
final class LocationProvider: NSObject, LocationProviding {
|
||||
|
||||
@@ -19,7 +177,7 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
super.init()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 请求坐标及高德逆地理编码地址。
|
||||
func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
try await ensureLocationPermission()
|
||||
@@ -51,7 +209,7 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 请求一次当前位置坐标。
|
||||
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
try await ensureLocationPermission()
|
||||
@@ -78,7 +236,7 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 使用高德服务将坐标解析为地址。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
@@ -110,3 +268,5 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
return manager
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -16,20 +16,25 @@ struct HomeLocationSnapshot: Sendable, Equatable {
|
||||
/// 定位服务协议,便于 ViewModel 注入与单元测试 mock。
|
||||
protocol LocationProviding: Sendable {
|
||||
/// 按指定精度请求当前坐标与地址。
|
||||
@MainActor
|
||||
func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot
|
||||
/// 按指定精度请求当前坐标。
|
||||
@MainActor
|
||||
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D
|
||||
/// 逆地理编码。
|
||||
@MainActor
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String
|
||||
}
|
||||
|
||||
extension LocationProviding {
|
||||
/// 请求当前坐标与地址,默认使用最高精度以保持既有业务行为。
|
||||
@MainActor
|
||||
func requestSnapshot() async throws -> HomeLocationSnapshot {
|
||||
try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest)
|
||||
}
|
||||
|
||||
/// 请求当前坐标,默认使用最高精度以保持既有业务行为。
|
||||
@MainActor
|
||||
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//
|
||||
// OfflineCollectionModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 线下收款方式,与线上订单支付方式保持独立。
|
||||
enum OfflineCollectionPaymentMethod: String, Codable, CaseIterable, Sendable, Hashable {
|
||||
case wechat = "WECHAT"
|
||||
case alipay = "ALIPAY"
|
||||
case cash = "CASH"
|
||||
|
||||
/// 页面展示的中文名称。
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .wechat: "微信"
|
||||
case .alipay: "支付宝"
|
||||
case .cash: "现金"
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面使用的本地矢量图标资源名称。
|
||||
var assetName: String {
|
||||
switch self {
|
||||
case .wechat: "payment_method_wechat"
|
||||
case .alipay: "payment_method_alipay"
|
||||
case .cash: "payment_method_cash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 线下收款记录的补缴状态。
|
||||
enum OfflineCollectionStatus: String, Codable, Sendable, Hashable {
|
||||
case pending = "PENDING"
|
||||
case settled = "SETTLED"
|
||||
case overdue = "OVERDUE"
|
||||
|
||||
/// 状态对应的中文文案。
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .pending: "待补缴"
|
||||
case .settled: "已补缴"
|
||||
case .overdue: "逾期未补缴"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前线下收款数据所属的用户、店铺与景区上下文。
|
||||
struct OfflineCollectionContext: Codable, Sendable, Hashable {
|
||||
let collectorId: String
|
||||
let collectorName: String
|
||||
let storeId: String
|
||||
let storeName: String
|
||||
let scenicId: String
|
||||
let scenicName: String
|
||||
|
||||
/// 生成用于 UserDefaults 隔离不同账号和店铺数据的稳定键。
|
||||
var storageScope: String {
|
||||
[collectorId, storeId, scenicId]
|
||||
.map { value in
|
||||
value.unicodeScalars.map { CharacterSet.alphanumerics.contains($0) ? String($0) : "_" }.joined()
|
||||
}
|
||||
.joined(separator: "_")
|
||||
}
|
||||
|
||||
/// 从当前登录上下文解析收款归属,缺少演示数据时使用文档约定的兜底值。
|
||||
static func current(appStore: AppStore = .shared) -> OfflineCollectionContext {
|
||||
let session = appStore.session
|
||||
let stores = appStore.permissions.rolePermissionList().flatMap(\.store)
|
||||
let currentStore = stores.first(where: { $0.id == session.currentStoreId }) ?? stores.first
|
||||
let collectorName = [session.realName, session.userName, session.accountDisplayName]
|
||||
.first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
|
||||
return OfflineCollectionContext(
|
||||
collectorId: session.userId.nonEmptyValue ?? "U10086",
|
||||
collectorName: collectorName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "张三",
|
||||
storeId: currentStore.map { String($0.id) } ?? (session.currentStoreId > 0 ? String(session.currentStoreId) : "S1001"),
|
||||
storeName: currentStore?.name.nonEmptyValue ?? "那拉提旅拍一店",
|
||||
scenicId: session.currentScenicId > 0 ? String(session.currentScenicId) : "SC001",
|
||||
scenicName: session.currentScenicName.nonEmptyValue ?? "那拉提景区"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 独立的线下收款记录,不包含任何订单字段。
|
||||
struct OfflineCollectionRecord: Codable, Sendable, Hashable {
|
||||
let id: String
|
||||
let collectorId: String
|
||||
let collectorName: String
|
||||
let storeId: String
|
||||
let storeName: String
|
||||
let scenicId: String
|
||||
let scenicName: String
|
||||
let amountFen: Int
|
||||
let paymentMethod: OfflineCollectionPaymentMethod
|
||||
let registeredAt: Date
|
||||
let receivedAt: Date
|
||||
let businessDate: String
|
||||
var status: OfflineCollectionStatus
|
||||
var settlementBatchId: String?
|
||||
var settledAt: Date?
|
||||
}
|
||||
|
||||
/// 线下收款的独立补缴流水,仅关联本批次收款记录快照。
|
||||
struct OfflineSettlementBatch: Codable, Sendable, Hashable {
|
||||
let id: String
|
||||
let businessDate: String
|
||||
let collectorId: String
|
||||
let collectorName: String
|
||||
let storeId: String
|
||||
let storeName: String
|
||||
let scenicId: String
|
||||
let scenicName: String
|
||||
let recordIds: [String]
|
||||
let recordCount: Int
|
||||
let amountFen: Int
|
||||
let paidAt: Date
|
||||
let payerId: String
|
||||
let payerName: String
|
||||
let clientRequestId: String
|
||||
|
||||
/// 流水类型固定为线下收款补缴。
|
||||
var transactionType: String { "OFFLINE_COLLECTION_SETTLEMENT" }
|
||||
}
|
||||
|
||||
/// 某一营业日的线下收款汇总。
|
||||
struct OfflineDailySummary: Sendable, Hashable {
|
||||
let businessDate: String
|
||||
let totalCount: Int
|
||||
let totalAmountFen: Int
|
||||
let settledCount: Int
|
||||
let settledAmountFen: Int
|
||||
let pendingCount: Int
|
||||
let pendingAmountFen: Int
|
||||
let hasOverdue: Bool
|
||||
|
||||
/// 按记录状态用整数“分”生成日汇总。
|
||||
static func make(businessDate: String, records: [OfflineCollectionRecord]) -> OfflineDailySummary {
|
||||
let settled = records.filter { $0.status == .settled }
|
||||
let pending = records.filter { $0.status == .pending || $0.status == .overdue }
|
||||
return OfflineDailySummary(
|
||||
businessDate: businessDate,
|
||||
totalCount: records.count,
|
||||
totalAmountFen: records.reduce(0) { $0 + $1.amountFen },
|
||||
settledCount: settled.count,
|
||||
settledAmountFen: settled.reduce(0) { $0 + $1.amountFen },
|
||||
pendingCount: pending.count,
|
||||
pendingAmountFen: pending.reduce(0) { $0 + $1.amountFen },
|
||||
hasOverdue: pending.contains { $0.status == .overdue }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 补缴确认时冻结的记录和金额快照,用于防止处理中新记录被误结清。
|
||||
struct OfflineSettlementRequest: Sendable, Hashable {
|
||||
let businessDate: String
|
||||
let recordIds: [String]
|
||||
let amountFen: Int
|
||||
let clientRequestId: String
|
||||
}
|
||||
|
||||
/// 登记成功后页面反馈所需的最小数据。
|
||||
struct OfflineCollectionRegistrationReceipt: Sendable, Hashable {
|
||||
let record: OfflineCollectionRecord
|
||||
let summary: OfflineDailySummary
|
||||
}
|
||||
|
||||
/// 线下收款 Mock 流程可识别的业务异常。
|
||||
enum OfflineCollectionError: LocalizedError, Sendable, Equatable {
|
||||
case invalidAmount
|
||||
case createFailed
|
||||
case noPendingRecords
|
||||
case summaryMismatch
|
||||
case recordsChanged
|
||||
case settlementFailed
|
||||
case persistenceFailed
|
||||
|
||||
/// 供页面展示的中文错误提示。
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidAmount: "请输入0.01~99,999.99元的有效金额"
|
||||
case .createFailed: "登记失败,请重试"
|
||||
case .noPendingRecords: "当前营业日无待补缴记录"
|
||||
case .summaryMismatch: "金额汇总异常,请刷新数据"
|
||||
case .recordsChanged: "待补缴记录已变化,请重新确认"
|
||||
case .settlementFailed: "补缴失败,请重试"
|
||||
case .persistenceFailed: "本地数据保存失败,请重试"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 金额输入、整数分换算和展示的统一工具。
|
||||
enum OfflineCollectionMoney {
|
||||
static let maximumFen = 9_999_999
|
||||
|
||||
/// 判断文本是否可作为金额输入中间态。
|
||||
static func acceptsEditingText(_ text: String) -> Bool {
|
||||
guard !text.contains(where: { !$0.isNumber && $0 != "." }) else { return false }
|
||||
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count <= 2 else { return false }
|
||||
let wholeCount = parts.first?.count ?? 0
|
||||
let fractionCount = parts.count == 2 ? parts[1].count : 0
|
||||
return wholeCount <= 5 && fractionCount <= 2
|
||||
}
|
||||
|
||||
/// 将用户输入精确转换为整数“分”,不经过浮点计算。
|
||||
static func parseFen(_ rawValue: String) -> Int? {
|
||||
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, text == rawValue, acceptsEditingText(text), text != "." else { return nil }
|
||||
|
||||
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
|
||||
let wholeText = parts[0].isEmpty ? "0" : String(parts[0])
|
||||
let fractionText = parts.count == 2 ? String(parts[1]) : ""
|
||||
guard wholeText.allSatisfy(\.isNumber), fractionText.allSatisfy(\.isNumber),
|
||||
let whole = Int(wholeText) else { return nil }
|
||||
|
||||
let cents: Int
|
||||
switch fractionText.count {
|
||||
case 0: cents = 0
|
||||
case 1: cents = (Int(fractionText) ?? 0) * 10
|
||||
case 2: cents = Int(fractionText) ?? 0
|
||||
default: return nil
|
||||
}
|
||||
let amountFen = whole * 100 + cents
|
||||
guard amountFen > 0, amountFen <= maximumFen else { return nil }
|
||||
return amountFen
|
||||
}
|
||||
|
||||
/// 将整数分格式化为带人民币符号且固定两位小数的文本。
|
||||
static func display(_ amountFen: Int) -> String {
|
||||
let sign = amountFen < 0 ? "-" : ""
|
||||
let absolute = abs(amountFen)
|
||||
return "\(sign)¥\(absolute / 100).\(String(format: "%02d", absolute % 100))"
|
||||
}
|
||||
}
|
||||
|
||||
/// 营业日与页面时间文案的统一格式化工具。
|
||||
enum OfflineCollectionDate {
|
||||
static let dailyCutoffTime = "23:59:59"
|
||||
|
||||
/// 按指定日历生成 YYYY-MM-DD 营业日。
|
||||
static func businessDate(for date: Date, calendar: Calendar = .current) -> String {
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(
|
||||
format: "%04d-%02d-%02d",
|
||||
components.year ?? 0,
|
||||
components.month ?? 0,
|
||||
components.day ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
/// 将营业日文本解析为当地日期。
|
||||
static func date(from businessDate: String, calendar: Calendar = .current) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: businessDate)
|
||||
}
|
||||
|
||||
/// 将时间格式化为当日明细所需的 HH:mm。
|
||||
static func timeText(_ date: Date, calendar: Calendar = .current) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
/// 将时间格式化为补缴流水的完整时间。
|
||||
static func dateTimeText(_ date: Date, calendar: Calendar = .current) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = calendar
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
/// 去除首尾空白后的非空文本。
|
||||
var nonEmptyValue: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//
|
||||
// OfflineCollectionMockService.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 线下收款本地 Mock 服务,负责演示数据、持久化、逾期流转与幂等补缴。
|
||||
final class OfflineCollectionMockService: @unchecked Sendable {
|
||||
|
||||
/// App 内共享的线下收款 Mock 数据源。
|
||||
static let shared = OfflineCollectionMockService()
|
||||
|
||||
/// 用于本地持久化的数据快照。
|
||||
private struct Snapshot: Codable, Equatable {
|
||||
var records: [OfflineCollectionRecord]
|
||||
var batches: [OfflineSettlementBatch]
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let calendar: Calendar
|
||||
private let nowProvider: @Sendable () -> Date
|
||||
private let requestDelayNanoseconds: UInt64
|
||||
private let lock = NSLock()
|
||||
private var shouldFailNextCreation = false
|
||||
private var shouldFailNextSettlement = false
|
||||
|
||||
/// 创建 Mock 服务,测试可注入独立 UserDefaults、时间与请求延迟。
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
calendar: Calendar = .current,
|
||||
nowProvider: @escaping @Sendable () -> Date = Date.init,
|
||||
requestDelayNanoseconds: UInt64 = 450_000_000
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.calendar = calendar
|
||||
self.nowProvider = nowProvider
|
||||
self.requestDelayNanoseconds = requestDelayNanoseconds
|
||||
}
|
||||
|
||||
/// 当前设备时区下的今日营业日。
|
||||
var todayBusinessDate: String {
|
||||
OfflineCollectionDate.businessDate(for: nowProvider(), calendar: calendar)
|
||||
}
|
||||
|
||||
/// 获取某营业日汇总。
|
||||
func getDailySummary(date: String, context: OfflineCollectionContext) -> OfflineDailySummary {
|
||||
lock.withLock {
|
||||
var snapshot = loadSnapshotLocked(context: context)
|
||||
refreshOverdueLocked(snapshot: &snapshot, context: context)
|
||||
let records = matchingRecords(snapshot.records, date: date, context: context)
|
||||
return OfflineDailySummary.make(businessDate: date, records: records)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取某营业日明细,按登记时间倒序返回。
|
||||
func getDailyRecords(date: String, context: OfflineCollectionContext) -> [OfflineCollectionRecord] {
|
||||
lock.withLock {
|
||||
var snapshot = loadSnapshotLocked(context: context)
|
||||
refreshOverdueLocked(snapshot: &snapshot, context: context)
|
||||
return matchingRecords(snapshot.records, date: date, context: context)
|
||||
.sorted { $0.registeredAt > $1.registeredAt }
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取某营业日的成功补缴流水。
|
||||
func getSettlementBatches(date: String, context: OfflineCollectionContext) -> [OfflineSettlementBatch] {
|
||||
lock.withLock {
|
||||
let snapshot = loadSnapshotLocked(context: context)
|
||||
return snapshot.batches
|
||||
.filter { batch in
|
||||
batch.businessDate == date
|
||||
&& batch.collectorId == context.collectorId
|
||||
&& batch.storeId == context.storeId
|
||||
&& batch.scenicId == context.scenicId
|
||||
}
|
||||
.sorted { $0.paidAt > $1.paidAt }
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前用户与店铺的历史逾期营业日,按日期升序方便先处理最早欠款。
|
||||
func getOverdueSummaries(context: OfflineCollectionContext) -> [OfflineDailySummary] {
|
||||
lock.withLock {
|
||||
var snapshot = loadSnapshotLocked(context: context)
|
||||
refreshOverdueLocked(snapshot: &snapshot, context: context)
|
||||
let overdueDates: Set<String> = Set(snapshot.records.compactMap { record -> String? in
|
||||
guard matches(record, context: context), record.status == .overdue else { return nil }
|
||||
return record.businessDate
|
||||
})
|
||||
return overdueDates.sorted().map { date in
|
||||
OfflineDailySummary.make(
|
||||
businessDate: date,
|
||||
records: matchingRecords(snapshot.records, date: date, context: context)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取可查看的营业日,始终包含今日。
|
||||
func getAvailableBusinessDates(context: OfflineCollectionContext) -> [String] {
|
||||
lock.withLock {
|
||||
var snapshot = loadSnapshotLocked(context: context)
|
||||
refreshOverdueLocked(snapshot: &snapshot, context: context)
|
||||
var dates = Set(snapshot.records.filter { matches($0, context: context) }.map(\.businessDate))
|
||||
dates.insert(todayBusinessDate)
|
||||
return dates.sorted(by: >)
|
||||
}
|
||||
}
|
||||
|
||||
/// 新建一条待补缴线下收款记录,不触发任何订单流程。
|
||||
func createOfflineCollection(
|
||||
amountFen: Int,
|
||||
paymentMethod: OfflineCollectionPaymentMethod,
|
||||
context: OfflineCollectionContext
|
||||
) async throws -> OfflineCollectionRegistrationReceipt {
|
||||
guard amountFen > 0, amountFen <= OfflineCollectionMoney.maximumFen else {
|
||||
throw OfflineCollectionError.invalidAmount
|
||||
}
|
||||
await simulateRequestDelay()
|
||||
|
||||
return try lock.withLock {
|
||||
if shouldFailNextCreation {
|
||||
shouldFailNextCreation = false
|
||||
throw OfflineCollectionError.createFailed
|
||||
}
|
||||
|
||||
var snapshot = loadSnapshotLocked(context: context)
|
||||
refreshOverdueLocked(snapshot: &snapshot, context: context)
|
||||
let now = nowProvider()
|
||||
let businessDate = OfflineCollectionDate.businessDate(for: now, calendar: calendar)
|
||||
let sequence = snapshot.records.filter { $0.businessDate == businessDate }.count + 1
|
||||
let record = OfflineCollectionRecord(
|
||||
id: String(format: "OCR%@%04d", businessDate.replacingOccurrences(of: "-", with: ""), sequence),
|
||||
collectorId: context.collectorId,
|
||||
collectorName: context.collectorName,
|
||||
storeId: context.storeId,
|
||||
storeName: context.storeName,
|
||||
scenicId: context.scenicId,
|
||||
scenicName: context.scenicName,
|
||||
amountFen: amountFen,
|
||||
paymentMethod: paymentMethod,
|
||||
registeredAt: now,
|
||||
receivedAt: now,
|
||||
businessDate: businessDate,
|
||||
status: .pending,
|
||||
settlementBatchId: nil,
|
||||
settledAt: nil
|
||||
)
|
||||
snapshot.records.append(record)
|
||||
try saveSnapshotLocked(snapshot, context: context)
|
||||
let summary = OfflineDailySummary.make(
|
||||
businessDate: businessDate,
|
||||
records: matchingRecords(snapshot.records, date: businessDate, context: context)
|
||||
)
|
||||
return OfflineCollectionRegistrationReceipt(record: record, summary: summary)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按确认时冻结的记录快照执行全额补缴,同一请求标识只生成一条流水。
|
||||
func settleDailyCollections(
|
||||
request: OfflineSettlementRequest,
|
||||
context: OfflineCollectionContext
|
||||
) async throws -> OfflineSettlementBatch {
|
||||
await simulateRequestDelay()
|
||||
|
||||
return try lock.withLock {
|
||||
var snapshot = loadSnapshotLocked(context: context)
|
||||
refreshOverdueLocked(snapshot: &snapshot, context: context)
|
||||
|
||||
if let existing = snapshot.batches.first(where: { $0.clientRequestId == request.clientRequestId }) {
|
||||
return existing
|
||||
}
|
||||
if shouldFailNextSettlement {
|
||||
shouldFailNextSettlement = false
|
||||
throw OfflineCollectionError.settlementFailed
|
||||
}
|
||||
|
||||
let requestedIds = Set(request.recordIds)
|
||||
guard !requestedIds.isEmpty, requestedIds.count == request.recordIds.count else {
|
||||
throw OfflineCollectionError.noPendingRecords
|
||||
}
|
||||
let pendingRecords = snapshot.records.filter { record in
|
||||
requestedIds.contains(record.id)
|
||||
&& matches(record, context: context)
|
||||
&& record.businessDate == request.businessDate
|
||||
&& (record.status == .pending || record.status == .overdue)
|
||||
}
|
||||
guard pendingRecords.count == request.recordIds.count else {
|
||||
throw OfflineCollectionError.recordsChanged
|
||||
}
|
||||
let calculatedAmount = pendingRecords.reduce(0) { $0 + $1.amountFen }
|
||||
guard calculatedAmount == request.amountFen else {
|
||||
throw OfflineCollectionError.summaryMismatch
|
||||
}
|
||||
|
||||
let now = nowProvider()
|
||||
let batchSequence = snapshot.batches.filter { $0.businessDate == request.businessDate }.count + 1
|
||||
let batchId = String(
|
||||
format: "OCS%@%04d",
|
||||
request.businessDate.replacingOccurrences(of: "-", with: ""),
|
||||
batchSequence
|
||||
)
|
||||
let batch = OfflineSettlementBatch(
|
||||
id: batchId,
|
||||
businessDate: request.businessDate,
|
||||
collectorId: context.collectorId,
|
||||
collectorName: context.collectorName,
|
||||
storeId: context.storeId,
|
||||
storeName: context.storeName,
|
||||
scenicId: context.scenicId,
|
||||
scenicName: context.scenicName,
|
||||
recordIds: request.recordIds,
|
||||
recordCount: request.recordIds.count,
|
||||
amountFen: request.amountFen,
|
||||
paidAt: now,
|
||||
payerId: context.collectorId,
|
||||
payerName: context.collectorName,
|
||||
clientRequestId: request.clientRequestId
|
||||
)
|
||||
|
||||
// 流水与记录在同一份本地快照内一次性更新,避免出现金额已减但流水缺失。
|
||||
snapshot.batches.append(batch)
|
||||
for index in snapshot.records.indices where requestedIds.contains(snapshot.records[index].id) {
|
||||
snapshot.records[index].status = .settled
|
||||
snapshot.records[index].settlementBatchId = batch.id
|
||||
snapshot.records[index].settledAt = now
|
||||
}
|
||||
try saveSnapshotLocked(snapshot, context: context)
|
||||
return batch
|
||||
}
|
||||
}
|
||||
|
||||
/// 开发环境中使下一次登记失败,用于验收输入保留和重试。
|
||||
func simulateNextCreationFailure() {
|
||||
lock.withLock { shouldFailNextCreation = true }
|
||||
}
|
||||
|
||||
/// 开发环境中使下一次补缴失败,失败时不改变记录和流水。
|
||||
func simulateNextSettlementFailure() {
|
||||
lock.withLock { shouldFailNextSettlement = true }
|
||||
}
|
||||
|
||||
/// 将当前上下文恢复为文档约定的默认演示数据。
|
||||
func resetOfflineCollectionMockData(context: OfflineCollectionContext) {
|
||||
lock.withLock {
|
||||
let snapshot = makeDefaultSnapshot(context: context)
|
||||
try? saveSnapshotLocked(snapshot, context: context)
|
||||
shouldFailNextCreation = false
|
||||
shouldFailNextSettlement = false
|
||||
}
|
||||
}
|
||||
|
||||
private func matchingRecords(
|
||||
_ records: [OfflineCollectionRecord],
|
||||
date: String,
|
||||
context: OfflineCollectionContext
|
||||
) -> [OfflineCollectionRecord] {
|
||||
records.filter { $0.businessDate == date && matches($0, context: context) }
|
||||
}
|
||||
|
||||
private func matches(_ record: OfflineCollectionRecord, context: OfflineCollectionContext) -> Bool {
|
||||
record.collectorId == context.collectorId
|
||||
&& record.storeId == context.storeId
|
||||
&& record.scenicId == context.scenicId
|
||||
}
|
||||
|
||||
private func refreshOverdueLocked(snapshot: inout Snapshot, context: OfflineCollectionContext) {
|
||||
let today = todayBusinessDate
|
||||
var changed = false
|
||||
for index in snapshot.records.indices where matches(snapshot.records[index], context: context) {
|
||||
guard snapshot.records[index].status != .settled else { continue }
|
||||
let expected: OfflineCollectionStatus = snapshot.records[index].businessDate < today ? .overdue : .pending
|
||||
if snapshot.records[index].status != expected {
|
||||
snapshot.records[index].status = expected
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
try? saveSnapshotLocked(snapshot, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSnapshotLocked(context: OfflineCollectionContext) -> Snapshot {
|
||||
let key = storageKey(context: context)
|
||||
guard let data = defaults.data(forKey: key) else {
|
||||
let snapshot = makeDefaultSnapshot(context: context)
|
||||
try? saveSnapshotLocked(snapshot, context: context)
|
||||
return snapshot
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(Snapshot.self, from: data)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("线下收款 Mock 数据损坏,已恢复默认数据:\(error)")
|
||||
#endif
|
||||
let snapshot = makeDefaultSnapshot(context: context)
|
||||
try? saveSnapshotLocked(snapshot, context: context)
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSnapshotLocked(_ snapshot: Snapshot, context: OfflineCollectionContext) throws {
|
||||
do {
|
||||
defaults.set(try JSONEncoder().encode(snapshot), forKey: storageKey(context: context))
|
||||
} catch {
|
||||
throw OfflineCollectionError.persistenceFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func storageKey(context: OfflineCollectionContext) -> String {
|
||||
"offline_collection_mock_v1_\(context.storageScope)"
|
||||
}
|
||||
|
||||
private func makeDefaultSnapshot(context: OfflineCollectionContext) -> Snapshot {
|
||||
let todayDate = nowProvider()
|
||||
let today = OfflineCollectionDate.businessDate(for: todayDate, calendar: calendar)
|
||||
let previousDate = calendar.date(byAdding: .day, value: -1, to: todayDate) ?? todayDate
|
||||
let previousBusinessDate = OfflineCollectionDate.businessDate(for: previousDate, calendar: calendar)
|
||||
|
||||
let samples: [(String, Int, OfflineCollectionPaymentMethod, Date, OfflineCollectionStatus)] = [
|
||||
("OCR\(today.replacingOccurrences(of: "-", with: ""))0001", 29_900, .wechat, time(on: todayDate, hour: 10, minute: 21), .pending),
|
||||
("OCR\(today.replacingOccurrences(of: "-", with: ""))0002", 39_900, .alipay, time(on: todayDate, hour: 14, minute: 16), .pending),
|
||||
("OCR\(today.replacingOccurrences(of: "-", with: ""))0003", 19_900, .wechat, time(on: todayDate, hour: 17, minute: 42), .pending),
|
||||
("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0001", 30_000, .cash, time(on: previousDate, hour: 16, minute: 10), .overdue),
|
||||
("OCR\(previousBusinessDate.replacingOccurrences(of: "-", with: ""))0002", 20_000, .wechat, time(on: previousDate, hour: 19, minute: 32), .overdue),
|
||||
]
|
||||
|
||||
let records = samples.map { sample in
|
||||
let businessDate = sample.3 < calendar.startOfDay(for: todayDate) ? previousBusinessDate : today
|
||||
return OfflineCollectionRecord(
|
||||
id: sample.0,
|
||||
collectorId: context.collectorId,
|
||||
collectorName: context.collectorName,
|
||||
storeId: context.storeId,
|
||||
storeName: context.storeName,
|
||||
scenicId: context.scenicId,
|
||||
scenicName: context.scenicName,
|
||||
amountFen: sample.1,
|
||||
paymentMethod: sample.2,
|
||||
registeredAt: sample.3,
|
||||
receivedAt: sample.3,
|
||||
businessDate: businessDate,
|
||||
status: sample.4,
|
||||
settlementBatchId: nil,
|
||||
settledAt: nil
|
||||
)
|
||||
}
|
||||
return Snapshot(records: records, batches: [])
|
||||
}
|
||||
|
||||
private func time(on date: Date, hour: Int, minute: Int) -> Date {
|
||||
calendar.date(bySettingHour: hour, minute: minute, second: 0, of: date) ?? date
|
||||
}
|
||||
|
||||
private func simulateRequestDelay() async {
|
||||
guard requestDelayNanoseconds > 0 else { return }
|
||||
try? await Task.sleep(nanoseconds: requestDelayNanoseconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//
|
||||
// OfflineCollectionViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 收款首页线下收款区域的 ViewModel。
|
||||
final class OfflineCollectionHomeViewModel {
|
||||
private(set) var todaySummary: OfflineDailySummary
|
||||
private(set) var overdueSummaries: [OfflineDailySummary] = []
|
||||
|
||||
let context: OfflineCollectionContext
|
||||
let service: OfflineCollectionMockService
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
/// 使用当前账号上下文创建首页 ViewModel。
|
||||
init(
|
||||
context: OfflineCollectionContext = .current(),
|
||||
service: OfflineCollectionMockService = .shared
|
||||
) {
|
||||
self.context = context
|
||||
self.service = service
|
||||
todaySummary = OfflineDailySummary.make(businessDate: service.todayBusinessDate, records: [])
|
||||
load()
|
||||
}
|
||||
|
||||
/// 当前今日营业日。
|
||||
var todayBusinessDate: String { service.todayBusinessDate }
|
||||
|
||||
/// 最早一个需要处理的逾期营业日。
|
||||
var earliestOverdueBusinessDate: String? { overdueSummaries.first?.businessDate }
|
||||
|
||||
/// 历史逾期记录的合计笔数。
|
||||
var overdueRecordCount: Int { overdueSummaries.reduce(0) { $0 + $1.pendingCount } }
|
||||
|
||||
/// 历史逾期记录的合计待补缴金额。
|
||||
var overdueAmountFen: Int { overdueSummaries.reduce(0) { $0 + $1.pendingAmountFen } }
|
||||
|
||||
/// 从本地 Mock 服务重新读取今日和逾期汇总。
|
||||
func load() {
|
||||
todaySummary = service.getDailySummary(date: todayBusinessDate, context: context)
|
||||
overdueSummaries = service.getOverdueSummaries(context: context)
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 线下收款登记页 ViewModel,管理表单校验、提交防重和成功反馈。
|
||||
final class OfflineCollectionRegistrationViewModel {
|
||||
private(set) var amountText = ""
|
||||
private(set) var paymentMethod: OfflineCollectionPaymentMethod? = .wechat
|
||||
private(set) var isSubmitting = false
|
||||
private(set) var receipt: OfflineCollectionRegistrationReceipt?
|
||||
|
||||
let context: OfflineCollectionContext
|
||||
let service: OfflineCollectionMockService
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onRegistrationSuccess: ((OfflineCollectionRegistrationReceipt) -> Void)?
|
||||
|
||||
/// 使用指定收款上下文和 Mock 服务创建登记 ViewModel。
|
||||
init(
|
||||
context: OfflineCollectionContext = .current(),
|
||||
service: OfflineCollectionMockService = .shared
|
||||
) {
|
||||
self.context = context
|
||||
self.service = service
|
||||
}
|
||||
|
||||
/// 当前金额和收款方式是否允许提交。
|
||||
var canSubmit: Bool {
|
||||
!isSubmitting && OfflineCollectionMoney.parseFen(amountText) != nil && paymentMethod != nil
|
||||
}
|
||||
|
||||
/// 更新金额输入,仅接受数字、单个小数点和最多两位小数。
|
||||
func updateAmount(_ value: String) {
|
||||
guard OfflineCollectionMoney.acceptsEditingText(value) else { return }
|
||||
amountText = value
|
||||
receipt = nil
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
/// 选择线下收款方式。
|
||||
func selectPaymentMethod(_ method: OfflineCollectionPaymentMethod) {
|
||||
paymentMethod = method
|
||||
receipt = nil
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
/// 提交一笔线下收款登记,提交中的重复调用会被忽略。
|
||||
func submit() async {
|
||||
guard !isSubmitting else { return }
|
||||
guard let amountFen = OfflineCollectionMoney.parseFen(amountText) else {
|
||||
onShowMessage?(OfflineCollectionError.invalidAmount.localizedDescription)
|
||||
return
|
||||
}
|
||||
guard let paymentMethod else {
|
||||
onShowMessage?("请选择收款方式")
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
onStateChange?()
|
||||
defer {
|
||||
isSubmitting = false
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await service.createOfflineCollection(
|
||||
amountFen: amountFen,
|
||||
paymentMethod: paymentMethod,
|
||||
context: context
|
||||
)
|
||||
receipt = result
|
||||
onRegistrationSuccess?(result)
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 登记成功后开始下一笔,清空金额并保留本次收款方式。
|
||||
func startAnotherRegistration() {
|
||||
amountText = ""
|
||||
receipt = nil
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 日清页的补缴处理状态。
|
||||
enum OfflineSettlementPresentationState: Sendable, Equatable {
|
||||
case idle
|
||||
case processing
|
||||
case success(OfflineSettlementBatch)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
/// 线下收款日清页 ViewModel,负责单营业日汇总、快照冻结和全额补缴。
|
||||
final class OfflineCollectionDailyViewModel {
|
||||
private(set) var businessDate: String
|
||||
private(set) var summary: OfflineDailySummary
|
||||
private(set) var records: [OfflineCollectionRecord] = []
|
||||
private(set) var batches: [OfflineSettlementBatch] = []
|
||||
private(set) var availableBusinessDates: [String] = []
|
||||
private(set) var settlementState: OfflineSettlementPresentationState = .idle
|
||||
private(set) var preparedRequest: OfflineSettlementRequest?
|
||||
|
||||
let context: OfflineCollectionContext
|
||||
let service: OfflineCollectionMockService
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
/// 使用指定营业日、收款上下文和 Mock 服务创建日清 ViewModel。
|
||||
init(
|
||||
businessDate: String,
|
||||
context: OfflineCollectionContext = .current(),
|
||||
service: OfflineCollectionMockService = .shared
|
||||
) {
|
||||
self.businessDate = businessDate
|
||||
self.context = context
|
||||
self.service = service
|
||||
summary = OfflineDailySummary.make(businessDate: businessDate, records: [])
|
||||
load()
|
||||
}
|
||||
|
||||
/// 当前查看的是否为今日营业日。
|
||||
var isToday: Bool { businessDate == service.todayBusinessDate }
|
||||
|
||||
/// 当前是否可以发起补缴。
|
||||
var canSettle: Bool {
|
||||
summary.pendingAmountFen > 0 && summary.pendingCount > 0 && settlementState != .processing
|
||||
}
|
||||
|
||||
/// 从本地 Mock 服务加载当前营业日的全部数据。
|
||||
func load() {
|
||||
records = service.getDailyRecords(date: businessDate, context: context)
|
||||
summary = service.getDailySummary(date: businessDate, context: context)
|
||||
batches = service.getSettlementBatches(date: businessDate, context: context)
|
||||
availableBusinessDates = service.getAvailableBusinessDates(context: context)
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
/// 切换至已存在数据或今日的营业日。
|
||||
func selectBusinessDate(_ date: String) {
|
||||
guard availableBusinessDates.contains(date), settlementState != .processing else { return }
|
||||
businessDate = date
|
||||
preparedRequest = nil
|
||||
settlementState = .idle
|
||||
load()
|
||||
}
|
||||
|
||||
/// 按页面当前待补缴记录冻结一份不可编辑的补缴快照。
|
||||
func prepareSettlement() throws -> OfflineSettlementRequest {
|
||||
guard canSettle else { throw OfflineCollectionError.noPendingRecords }
|
||||
let pendingRecords = records.filter { $0.status == .pending || $0.status == .overdue }
|
||||
let amountFen = pendingRecords.reduce(0) { $0 + $1.amountFen }
|
||||
guard pendingRecords.count == summary.pendingCount, amountFen == summary.pendingAmountFen else {
|
||||
throw OfflineCollectionError.summaryMismatch
|
||||
}
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: businessDate,
|
||||
recordIds: pendingRecords.map(\.id),
|
||||
amountFen: amountFen,
|
||||
clientRequestId: "LOCAL-\(UUID().uuidString.uppercased())"
|
||||
)
|
||||
preparedRequest = request
|
||||
return request
|
||||
}
|
||||
|
||||
/// 取消未提交的补缴确认快照。
|
||||
func cancelPreparedSettlement() {
|
||||
guard settlementState != .processing else { return }
|
||||
preparedRequest = nil
|
||||
}
|
||||
|
||||
/// 提交已冻结的补缴快照,失败后保留快照以便原样重试。
|
||||
func confirmSettlement() async {
|
||||
guard settlementState != .processing else { return }
|
||||
guard let request = preparedRequest else {
|
||||
settlementState = .failed(OfflineCollectionError.noPendingRecords.localizedDescription)
|
||||
onStateChange?()
|
||||
return
|
||||
}
|
||||
|
||||
settlementState = .processing
|
||||
onStateChange?()
|
||||
do {
|
||||
let batch = try await service.settleDailyCollections(request: request, context: context)
|
||||
preparedRequest = nil
|
||||
settlementState = .success(batch)
|
||||
load()
|
||||
} catch {
|
||||
settlementState = .failed(error.localizedDescription)
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
/// 将成功或失败反馈恢复为可操作状态。
|
||||
func clearSettlementFeedback() {
|
||||
guard settlementState != .processing else { return }
|
||||
settlementState = .idle
|
||||
onStateChange?()
|
||||
}
|
||||
|
||||
/// 开发环境下将下一次 Mock 补缴设为失败。
|
||||
func simulateNextSettlementFailure() {
|
||||
service.simulateNextSettlementFailure()
|
||||
}
|
||||
}
|
||||
@@ -293,6 +293,28 @@ final class OrderAPI {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取指定获客员的分成比例修改记录。
|
||||
func saleUserCommissionRateLogs(
|
||||
saleUserId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 10
|
||||
) async throws -> CommissionRateLogListResponse {
|
||||
guard saleUserId > 0 else {
|
||||
throw APIError.networkFailed("获客员信息无效")
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/sale-user/commission-rate/logs",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "sale_user_id", value: String(saleUserId)),
|
||||
URLQueryItem(name: "page", value: String(max(1, page))),
|
||||
URLQueryItem(name: "page_size", value: String(min(max(1, pageSize), 50))),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
|
||||
@@ -875,6 +875,93 @@ struct CooperativeSalerEntity: Decodable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获客员分成比例修改记录分页响应。
|
||||
struct CommissionRateLogListResponse: Decodable, Equatable {
|
||||
let list: [CommissionRateLogEntity]
|
||||
let total: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case list
|
||||
case total
|
||||
case page
|
||||
case pageSize = "page_size"
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条获客员分成比例修改记录。
|
||||
struct CommissionRateLogEntity: Decodable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let bindingId: Int
|
||||
let saleUserId: Int
|
||||
let storeUserId: Int
|
||||
let beforeRate: Int
|
||||
let beforeRateLabel: String
|
||||
let afterRate: Int
|
||||
let afterRateLabel: String
|
||||
let operatorType: String
|
||||
let operatorId: Int
|
||||
let operatorName: String
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case bindingId = "binding_id"
|
||||
case saleUserId = "sale_user_id"
|
||||
case storeUserId = "store_user_id"
|
||||
case beforeRate = "before_rate"
|
||||
case beforeRateLabel = "before_rate_label"
|
||||
case afterRate = "after_rate"
|
||||
case afterRateLabel = "after_rate_label"
|
||||
case operatorType = "operator_type"
|
||||
case operatorId = "operator_id"
|
||||
case operatorName = "operator_name"
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 修改前比例展示文本,优先使用服务端 label。
|
||||
var displayBeforeRate: String {
|
||||
let label = beforeRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return label.isEmpty ? "\(beforeRate)%" : label
|
||||
}
|
||||
|
||||
/// 修改后比例展示文本,优先使用服务端 label。
|
||||
var displayAfterRate: String {
|
||||
let label = afterRateLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return label.isEmpty ? "\(afterRate)%" : label
|
||||
}
|
||||
|
||||
/// 修改时间展示文本。
|
||||
var metadataLine: String {
|
||||
formattedCreatedAt
|
||||
}
|
||||
|
||||
/// 去除首尾空白后的备注;空备注不展示。
|
||||
var displayRemark: String? {
|
||||
let value = remark.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
private var formattedCreatedAt: String {
|
||||
let value = createdAt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return "—" }
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
||||
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm"] {
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: value) {
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
struct ReferralOrderListResponse: Decodable {
|
||||
let total: Int
|
||||
let list: [ReferralOrderEntity]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import UIKit
|
||||
|
||||
/// 主 Tab 中间扫码按钮结果处理,对齐 Android `MainTabViewModel.signIn`。
|
||||
|
||||
@@ -69,9 +69,9 @@ enum PayPageBrandingPolicy {
|
||||
|
||||
/// 收款页当前账号信息的展示文本格式化规则。
|
||||
enum PaymentAccountDisplayFormatter {
|
||||
/// 解析当前登录摄影师名称,昵称为空时使用实名,仍为空时显示占位符。
|
||||
static func photographerName(userName: String, realName: String) -> String {
|
||||
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-"
|
||||
/// 解析当前登录摄影师的真实姓名,空值显示占位符。
|
||||
static func photographerName(realName: String) -> String {
|
||||
realName.trimmedNonEmpty ?? "-"
|
||||
}
|
||||
|
||||
/// 去除空值与重复项后,合并当前账号的全部店铺名称。
|
||||
|
||||
@@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
|
||||
|
||||
private var staticPayURL = ""
|
||||
private var dynamicPayURL = ""
|
||||
private var hasCompletedPayCodeRequest = false
|
||||
private let appStore: AppStore
|
||||
private let payPageConfigCache: PayPageConfigCaching
|
||||
|
||||
@@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
|
||||
qrImage != nil && !staticPayURL.isEmpty
|
||||
}
|
||||
|
||||
/// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。
|
||||
var isPayCodeLoading: Bool {
|
||||
loading || !hasCompletedPayCodeRequest
|
||||
}
|
||||
|
||||
var displayScenicName: String {
|
||||
scenicName.isEmpty ? "暂无景区" : scenicName
|
||||
}
|
||||
@@ -60,7 +66,6 @@ final class PaymentCollectionDetailsViewModel {
|
||||
|
||||
var displayPhotographerName: String {
|
||||
PaymentAccountDisplayFormatter.photographerName(
|
||||
userName: appStore.session.userName,
|
||||
realName: appStore.session.realName
|
||||
)
|
||||
}
|
||||
@@ -85,11 +90,11 @@ final class PaymentCollectionDetailsViewModel {
|
||||
|
||||
func loadPayCode(api: PaymentAPI) async {
|
||||
guard !loading else { return }
|
||||
refreshLocalState()
|
||||
loading = true
|
||||
notifyStateChange()
|
||||
refreshLocalState()
|
||||
defer {
|
||||
loading = false
|
||||
hasCompletedPayCodeRequest = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ final class ProfileViewModel {
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
var displayNickname: String {
|
||||
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
|
||||
nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
|
||||
}
|
||||
|
||||
var displayRealName: String {
|
||||
nonEmpty(userInfo?.realName) ?? "--"
|
||||
nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
|
||||
}
|
||||
|
||||
var displayPhone: String {
|
||||
@@ -37,6 +37,17 @@ final class ProfileViewModel {
|
||||
return uid.isEmpty ? "--" : uid
|
||||
}
|
||||
|
||||
/// 本地是否已有可供冷启动首屏展示的基本资料。
|
||||
var hasCachedBasicInfo: Bool {
|
||||
[
|
||||
AppStore.shared.session.userName,
|
||||
AppStore.shared.session.realName,
|
||||
AppStore.shared.session.phone,
|
||||
AppStore.shared.session.avatar,
|
||||
AppStore.shared.session.userId,
|
||||
].contains { nonEmpty($0) != nil }
|
||||
}
|
||||
|
||||
var accountDisplayName: String {
|
||||
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "--" : name
|
||||
@@ -112,6 +123,7 @@ final class ProfileViewModel {
|
||||
if !info.roleName.isEmpty {
|
||||
AppStore.shared.session.roleName = info.roleName
|
||||
}
|
||||
notifyStateChange()
|
||||
|
||||
if showPhotographerFields {
|
||||
async let realName = api.realNameInfo()
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// TravelAlbumAutoRetouchModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 自动修图第一级方式,页面上始终只展示“不修图”和“AI 修图”两个选项。
|
||||
enum TravelAlbumRetouchMode: String, CaseIterable, Hashable, Sendable {
|
||||
case disabled
|
||||
case aiRetouch
|
||||
|
||||
/// 修图方式的用户可见名称。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .disabled:
|
||||
return "不修图"
|
||||
case .aiRetouch:
|
||||
return "AI 修图"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册自动 AI 修图配置,用于在新建相册与照片上传页之间传递模板选择。
|
||||
struct TravelAlbumAutoRetouchConfiguration: Codable, Equatable, Sendable {
|
||||
let isEnabled: Bool
|
||||
let templateID: String?
|
||||
|
||||
/// 默认不启用自动修图。
|
||||
static let disabled = TravelAlbumAutoRetouchConfiguration(isEnabled: false, templateID: nil)
|
||||
|
||||
/// 根据已选模板创建启用状态配置。
|
||||
static func enabled(templateID: String) -> TravelAlbumAutoRetouchConfiguration {
|
||||
TravelAlbumAutoRetouchConfiguration(isEnabled: true, templateID: templateID)
|
||||
}
|
||||
|
||||
/// 当前配置对应的修图模板。
|
||||
var template: TravelAlbumEditPreset? {
|
||||
guard isEnabled, let templateID else { return nil }
|
||||
return TravelAlbumEditPreset.autoRetouchOptions.first { $0.id == templateID }
|
||||
}
|
||||
|
||||
/// 是否可以作为完整的自动修图配置提交。
|
||||
var isValid: Bool {
|
||||
!isEnabled || template != nil
|
||||
}
|
||||
|
||||
/// 上传页紧凑设置项的展示文案。
|
||||
var uploadOptionTitle: String {
|
||||
isEnabled ? TravelAlbumRetouchMode.aiRetouch.title : TravelAlbumRetouchMode.disabled.title
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumEditPreset {
|
||||
/// 自动修图可选模板,不包含手动修图中的“还原为原图”。
|
||||
static var autoRetouchOptions: [TravelAlbumEditPreset] {
|
||||
defaultOptions.filter { $0.effect != .original }
|
||||
}
|
||||
}
|
||||
|
||||
/// 单张 OTG 照片的自动 AI 修图状态,与原有上传状态分开计算。
|
||||
enum TravelAlbumAutoRetouchState: String, Codable, Equatable, Sendable {
|
||||
case none = "NONE"
|
||||
case processing = "PROCESSING"
|
||||
case completed = "COMPLETED"
|
||||
case failed = "FAILED"
|
||||
}
|
||||
|
||||
/// 相册自动修图配置读写接口,便于 ViewModel 与单元测试注入。
|
||||
protocol TravelAlbumAutoRetouchConfigurationStoring: AnyObject {
|
||||
/// 读取指定服务端相册的配置。
|
||||
func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration
|
||||
|
||||
/// 保存指定服务端相册的配置。
|
||||
func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int)
|
||||
|
||||
/// 删除指定相册的本地配置。
|
||||
func remove(albumID: Int)
|
||||
}
|
||||
|
||||
/// 使用 UserDefaults 按服务端相册 ID 持久化自动 AI 修图配置。
|
||||
final class TravelAlbumAutoRetouchConfigurationStore: TravelAlbumAutoRetouchConfigurationStoring {
|
||||
static let shared = TravelAlbumAutoRetouchConfigurationStore()
|
||||
|
||||
private let userDefaults: UserDefaults
|
||||
private let keyPrefix: String
|
||||
|
||||
/// 创建配置存储;测试可传入独立 UserDefaults suite。
|
||||
init(
|
||||
userDefaults: UserDefaults = .standard,
|
||||
keyPrefix: String = "travelAlbum.autoRetouch.album"
|
||||
) {
|
||||
self.userDefaults = userDefaults
|
||||
self.keyPrefix = keyPrefix
|
||||
}
|
||||
|
||||
func configuration(albumID: Int) -> TravelAlbumAutoRetouchConfiguration {
|
||||
guard albumID > 0,
|
||||
let data = userDefaults.data(forKey: key(albumID: albumID)),
|
||||
let configuration = try? JSONDecoder().decode(TravelAlbumAutoRetouchConfiguration.self, from: data),
|
||||
configuration.isValid else {
|
||||
return .disabled
|
||||
}
|
||||
return configuration
|
||||
}
|
||||
|
||||
func save(_ configuration: TravelAlbumAutoRetouchConfiguration, albumID: Int) {
|
||||
guard albumID > 0 else { return }
|
||||
let normalized = configuration.isValid ? configuration : .disabled
|
||||
guard let data = try? JSONEncoder().encode(normalized) else { return }
|
||||
userDefaults.set(data, forKey: key(albumID: albumID))
|
||||
}
|
||||
|
||||
func remove(albumID: Int) {
|
||||
guard albumID > 0 else { return }
|
||||
userDefaults.removeObject(forKey: key(albumID: albumID))
|
||||
}
|
||||
|
||||
private func key(albumID: Int) -> String {
|
||||
"\(keyPrefix).\(albumID)"
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
|
||||
var errorMessage: String?
|
||||
let localPath: String
|
||||
var remoteUrl: String
|
||||
var autoRetouchState: TravelAlbumAutoRetouchState
|
||||
var autoRetouchTemplateId: String?
|
||||
|
||||
/// 是否未上传完成。
|
||||
var isNotUploaded: Bool {
|
||||
@@ -346,7 +348,8 @@ enum TravelAlbumOTGPhotoFormatMatcher {
|
||||
extension TravelAlbumOTGPhotoRecord {
|
||||
/// 转为页面展示项。
|
||||
func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem {
|
||||
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in
|
||||
let preferredRetouchedPath = autoRetouchState == .completed ? retouchedPath : ""
|
||||
let previewPath = [preferredRetouchedPath, thumbnailPath, localPath, remoteUrl].first { path in
|
||||
guard !path.isEmpty else { return false }
|
||||
if path.hasPrefix("http") { return true }
|
||||
return storage.fileExists(relativePath: path, albumId: albumId)
|
||||
@@ -371,7 +374,9 @@ extension TravelAlbumOTGPhotoRecord {
|
||||
progress: progress,
|
||||
errorMessage: errorMessage,
|
||||
localPath: localPath,
|
||||
remoteUrl: remoteUrl
|
||||
remoteUrl: remoteUrl,
|
||||
autoRetouchState: autoRetouchState,
|
||||
autoRetouchTemplateId: autoRetouchTemplateId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
let albumId: Int
|
||||
let userId: String
|
||||
var remoteUrl: String
|
||||
var materialId: Int?
|
||||
var autoRetouchState: TravelAlbumAutoRetouchState
|
||||
var autoRetouchTemplateId: String?
|
||||
var retouchedPath: String
|
||||
var updatedAt: Int64
|
||||
|
||||
/// 创建 OTG 本地照片记录。
|
||||
@@ -74,6 +78,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
albumId: Int,
|
||||
userId: String,
|
||||
remoteUrl: String = "",
|
||||
materialId: Int? = nil,
|
||||
autoRetouchState: TravelAlbumAutoRetouchState = .none,
|
||||
autoRetouchTemplateId: String? = nil,
|
||||
retouchedPath: String = "",
|
||||
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
) {
|
||||
self.id = id
|
||||
@@ -90,12 +98,17 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
self.albumId = albumId
|
||||
self.userId = userId
|
||||
self.remoteUrl = remoteUrl
|
||||
self.materialId = materialId
|
||||
self.autoRetouchState = autoRetouchState
|
||||
self.autoRetouchTemplateId = autoRetouchTemplateId
|
||||
self.retouchedPath = retouchedPath
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
|
||||
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
|
||||
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, materialId
|
||||
case autoRetouchState, autoRetouchTemplateId, retouchedPath, updatedAt
|
||||
}
|
||||
|
||||
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
|
||||
@@ -115,16 +128,30 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
albumId = try container.decode(Int.self, forKey: .albumId)
|
||||
userId = try container.decode(String.self, forKey: .userId)
|
||||
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? ""
|
||||
materialId = try container.decodeIfPresent(Int.self, forKey: .materialId)
|
||||
autoRetouchState = try container.decodeIfPresent(
|
||||
TravelAlbumAutoRetouchState.self,
|
||||
forKey: .autoRetouchState
|
||||
) ?? .none
|
||||
autoRetouchTemplateId = try container.decodeIfPresent(String.self, forKey: .autoRetouchTemplateId)
|
||||
retouchedPath = try container.decodeIfPresent(String.self, forKey: .retouchedPath) ?? ""
|
||||
updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
|
||||
}
|
||||
|
||||
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
|
||||
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
|
||||
guard status == .transferring || status == .uploading else { return self }
|
||||
let interruptedUpload = status == .transferring || status == .uploading
|
||||
let interruptedRetouch = autoRetouchState == .processing
|
||||
guard interruptedUpload || interruptedRetouch else { return self }
|
||||
var copy = self
|
||||
copy.status = .pending
|
||||
copy.progress = 0
|
||||
if interruptedUpload {
|
||||
copy.status = copy.materialId == nil ? .pending : .uploaded
|
||||
copy.progress = copy.materialId == nil ? 0 : 100
|
||||
copy.errorMessage = nil
|
||||
}
|
||||
if interruptedRetouch {
|
||||
copy.autoRetouchState = copy.materialId == nil ? .none : .failed
|
||||
}
|
||||
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
return copy
|
||||
}
|
||||
@@ -242,7 +269,7 @@ final class TravelAlbumOTGPhotoStore {
|
||||
?? TravelAlbumClientPhotoID.make()
|
||||
return copy.normalizedAfterInterruptedTransfer()
|
||||
}
|
||||
if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) {
|
||||
if migrated != scoped {
|
||||
save(migrated, albumId: albumId)
|
||||
}
|
||||
return migrated
|
||||
@@ -333,6 +360,22 @@ final class TravelAlbumOTGPhotoStore {
|
||||
return url
|
||||
}
|
||||
|
||||
/// 写入自动 AI 修图结果,返回当前相册预览目录中的文件 URL。
|
||||
func writeRetouchedImage(_ data: Data, filename: String, albumId: Int) throws -> URL {
|
||||
let directory = try previewsDirectory(albumId: albumId)
|
||||
let sanitized = PTPHelper.sanitizeFilename(filename)
|
||||
let stem = (sanitized as NSString).deletingPathExtension
|
||||
let preferredName = "\(stem)_retouched.jpg"
|
||||
var candidate = directory.appendingPathComponent(preferredName)
|
||||
var counter = 1
|
||||
while fileManager.fileExists(atPath: candidate.path), counter < 10_000 {
|
||||
candidate = directory.appendingPathComponent("\(stem)_retouched_\(counter).jpg")
|
||||
counter += 1
|
||||
}
|
||||
try data.write(to: candidate, options: .atomic)
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// 返回写入索引用的相对路径。
|
||||
func relativePath(for url: URL, albumId: Int) -> String {
|
||||
let path = url.standardizedFileURL.path
|
||||
@@ -382,6 +425,9 @@ final class TravelAlbumOTGPhotoStore {
|
||||
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
|
||||
resolved.thumbnailPath = thumbnailURL.path
|
||||
}
|
||||
if let retouchedURL = absoluteURL(for: record.retouchedPath, albumId: albumId) {
|
||||
resolved.retouchedPath = retouchedURL.path
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -433,7 +479,7 @@ final class TravelAlbumOTGPhotoStore {
|
||||
}
|
||||
|
||||
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) {
|
||||
[record.localPath, record.thumbnailPath].forEach { path in
|
||||
[record.localPath, record.thumbnailPath, record.retouchedPath].forEach { path in
|
||||
guard !path.isEmpty else { return }
|
||||
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
|
||||
try? fileManager.removeItem(at: url)
|
||||
@@ -463,6 +509,11 @@ final class TravelAlbumOTGPhotoStore {
|
||||
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
|
||||
: record.thumbnailPath
|
||||
}
|
||||
if !record.retouchedPath.isEmpty {
|
||||
normalized.retouchedPath = record.retouchedPath.hasPrefix("/")
|
||||
? relativePath(for: URL(fileURLWithPath: record.retouchedPath), albumId: albumId)
|
||||
: record.retouchedPath
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,18 @@ final class TravelAlbumAIEditResultStore {
|
||||
notifyChange(materialID: materialID)
|
||||
}
|
||||
|
||||
/// 使用已编码图片数据保存自动修图结果,供无 UIKit 依赖的 ViewModel 调用。
|
||||
@discardableResult
|
||||
func completeFromImageData(
|
||||
materialID: Int,
|
||||
presetID: String,
|
||||
editedImageData: Data
|
||||
) -> Bool {
|
||||
guard let image = UIImage(data: editedImageData) else { return false }
|
||||
complete(materialID: materialID, presetID: presetID, editedImage: image)
|
||||
return true
|
||||
}
|
||||
|
||||
/// 修图失败时恢复按钮可用状态,并保留之前成功的结果。
|
||||
func failProcessing(materialID: Int) {
|
||||
guard var record = recordsByMaterialID[materialID] else { return }
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// TravelAlbumAutoRetouchProcessor.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// 自动 AI 修图处理接口,把本地原图与已选模板转换为可持久化的结果数据。
|
||||
@MainActor
|
||||
protocol TravelAlbumAutoRetouchProcessing {
|
||||
/// 按指定模板处理一张本地照片。
|
||||
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data
|
||||
}
|
||||
|
||||
/// 使用项目现有 Core Image 效果生成自动修图演示结果。
|
||||
@MainActor
|
||||
final class TravelAlbumAutoRetouchProcessor: TravelAlbumAutoRetouchProcessing {
|
||||
private let processingDelayNanoseconds: UInt64
|
||||
|
||||
/// 创建本地修图处理器,默认保留短暂处理时间用于展示“修图中”状态。
|
||||
init(processingDelayNanoseconds: UInt64 = 900_000_000) {
|
||||
self.processingDelayNanoseconds = processingDelayNanoseconds
|
||||
}
|
||||
|
||||
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data {
|
||||
let sourceData = try Data(contentsOf: sourceURL)
|
||||
guard let sourceImage = UIImage(data: sourceData) else {
|
||||
throw TravelAlbumAutoRetouchError.invalidSourceImage
|
||||
}
|
||||
if processingDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: processingDelayNanoseconds)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
let editedImage = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: sourceImage)
|
||||
guard let resultData = editedImage.jpegData(compressionQuality: 0.92) else {
|
||||
throw TravelAlbumAutoRetouchError.resultEncodingFailed
|
||||
}
|
||||
return resultData
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地自动 AI 修图过程的可展示错误。
|
||||
enum TravelAlbumAutoRetouchError: LocalizedError, Equatable {
|
||||
case invalidSourceImage
|
||||
case resultEncodingFailed
|
||||
case invalidImageData
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidSourceImage:
|
||||
return "原图无法读取,修图失败"
|
||||
case .resultEncodingFailed:
|
||||
return "修图结果生成失败"
|
||||
case .invalidImageData:
|
||||
return "修图结果无法读取"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,16 @@ final class TravelAlbumEntryViewModel {
|
||||
|
||||
private let currentScenicIdProvider: () -> Int
|
||||
private let dateProvider: () -> Date
|
||||
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
|
||||
|
||||
init(
|
||||
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId },
|
||||
dateProvider: @escaping () -> Date = Date.init
|
||||
dateProvider: @escaping () -> Date = Date.init,
|
||||
autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared
|
||||
) {
|
||||
self.currentScenicIdProvider = currentScenicIdProvider
|
||||
self.dateProvider = dateProvider
|
||||
self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
|
||||
}
|
||||
|
||||
/// 重新拉取相册列表。
|
||||
@@ -110,6 +113,7 @@ final class TravelAlbumEntryViewModel {
|
||||
freeCount: String,
|
||||
singlePrice: String,
|
||||
packagePrice: String,
|
||||
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
|
||||
order: TravelAlbumAvailableOrder?,
|
||||
api: any TravelAlbumServing
|
||||
) async {
|
||||
@@ -132,6 +136,10 @@ final class TravelAlbumEntryViewModel {
|
||||
onShowMessage?("请输入有效的单张照片价格")
|
||||
return
|
||||
}
|
||||
if !autoRetouchConfiguration.isValid {
|
||||
onShowMessage?("请选择修图模板")
|
||||
return
|
||||
}
|
||||
|
||||
let albumName: String
|
||||
switch mode {
|
||||
@@ -174,6 +182,7 @@ final class TravelAlbumEntryViewModel {
|
||||
|
||||
do {
|
||||
let response = try await api.create(request)
|
||||
autoRetouchConfigurationStore.save(autoRetouchConfiguration, albumID: response.id)
|
||||
isCreateSheetVisible = false
|
||||
onShowMessage?("任务创建成功")
|
||||
onCreatedAlbum?(response)
|
||||
|
||||
@@ -63,7 +63,7 @@ final class WiredCameraTransferViewModel {
|
||||
private(set) var sonyMTPHint: String?
|
||||
private(set) var isContentCatalogReady = false
|
||||
|
||||
let retouchOption = "不修图"
|
||||
private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
|
||||
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
|
||||
didSet { notifyStateChanged() }
|
||||
@@ -80,6 +80,9 @@ final class WiredCameraTransferViewModel {
|
||||
private let api: any TravelAlbumServing
|
||||
private let appStore: AppStore
|
||||
private let userDefaults: UserDefaults
|
||||
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
|
||||
private let autoRetouchProcessor: any TravelAlbumAutoRetouchProcessing
|
||||
private let aiEditResultStore: TravelAlbumAIEditResultStore
|
||||
|
||||
private var currentDriver: CameraDriver?
|
||||
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
|
||||
@@ -100,7 +103,10 @@ final class WiredCameraTransferViewModel {
|
||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||
api: (any TravelAlbumServing)? = nil,
|
||||
appStore: AppStore = .shared,
|
||||
userDefaults: UserDefaults = .standard
|
||||
userDefaults: UserDefaults = .standard,
|
||||
autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring = TravelAlbumAutoRetouchConfigurationStore.shared,
|
||||
autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil,
|
||||
aiEditResultStore: TravelAlbumAIEditResultStore? = nil
|
||||
) {
|
||||
self.albumId = albumId
|
||||
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
|
||||
@@ -113,6 +119,10 @@ final class WiredCameraTransferViewModel {
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
self.appStore = appStore
|
||||
self.userDefaults = userDefaults
|
||||
self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
|
||||
self.autoRetouchProcessor = autoRetouchProcessor ?? TravelAlbumAutoRetouchProcessor()
|
||||
self.aiEditResultStore = aiEditResultStore ?? .shared
|
||||
self.autoRetouchConfiguration = autoRetouchConfigurationStore.configuration(albumID: albumId)
|
||||
// 从相册管理选择模式进入时,以本次选择为准;其他旧入口继续沿用上次记录。
|
||||
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
|
||||
if let initialTransferMode {
|
||||
@@ -218,6 +228,11 @@ final class WiredCameraTransferViewModel {
|
||||
transferMode.title
|
||||
}
|
||||
|
||||
/// 自动 AI 修图设置项展示文案。
|
||||
var retouchOption: String {
|
||||
autoRetouchConfiguration.uploadOptionTitle
|
||||
}
|
||||
|
||||
/// 指定上传弹窗选项。
|
||||
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
|
||||
TravelAlbumOTGSpecifyUploadOption.allCases
|
||||
@@ -306,6 +321,15 @@ final class WiredCameraTransferViewModel {
|
||||
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
|
||||
}
|
||||
|
||||
/// 更新当前相册的自动 AI 修图配置,只影响尚未开始的上传任务。
|
||||
func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) {
|
||||
let normalized = configuration.isValid ? configuration : .disabled
|
||||
guard normalized != autoRetouchConfiguration else { return }
|
||||
autoRetouchConfiguration = normalized
|
||||
autoRetouchConfigurationStore.save(normalized, albumID: albumId)
|
||||
notifyStateChanged()
|
||||
}
|
||||
|
||||
/// 切换上传格式。
|
||||
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
|
||||
guard photoFormatOption != option else { return }
|
||||
@@ -467,8 +491,31 @@ final class WiredCameraTransferViewModel {
|
||||
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
|
||||
}
|
||||
|
||||
/// 使用该照片上次失败时的模板重试自动修图,不重复上传原图。
|
||||
func retryAutoRetouch(photoId: String) {
|
||||
guard let record = persistedRecordsById[photoId],
|
||||
record.autoRetouchState == .failed,
|
||||
let materialId = record.materialId,
|
||||
let templateID = record.autoRetouchTemplateId,
|
||||
let preset = TravelAlbumEditPreset.autoRetouchOptions.first(where: { $0.id == templateID }) else {
|
||||
showMessage("暂无可重试的修图任务")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await processAutoRetouch(
|
||||
photoId: photoId,
|
||||
sourceRecord: record,
|
||||
materialId: materialId,
|
||||
preset: preset
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除单张本地照片。
|
||||
func deletePhoto(photoId: String) {
|
||||
if let materialId = persistedRecordsById[photoId]?.materialId {
|
||||
aiEditResultStore.remove(materialID: materialId)
|
||||
}
|
||||
storage.remove(albumId: albumId, photoId: photoId)
|
||||
persistedRecordsById.removeValue(forKey: photoId)
|
||||
selectedPhotoIds.remove(photoId)
|
||||
@@ -478,6 +525,9 @@ final class WiredCameraTransferViewModel {
|
||||
|
||||
/// 清空当前相册本地 OTG 缓存。
|
||||
func clearLocalAlbumCache() {
|
||||
persistedRecordsById.values.compactMap(\.materialId).forEach { materialId in
|
||||
aiEditResultStore.remove(materialID: materialId)
|
||||
}
|
||||
storage.clearAlbum(albumId: albumId)
|
||||
persistedRecordsById = [:]
|
||||
photos = []
|
||||
@@ -492,9 +542,27 @@ final class WiredCameraTransferViewModel {
|
||||
private func loadPersistedPhotos() {
|
||||
let records = storage.load(albumId: albumId)
|
||||
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
|
||||
restorePersistedAutoRetouchResults(records)
|
||||
applyMergedPhotos()
|
||||
}
|
||||
|
||||
private func restorePersistedAutoRetouchResults(_ records: [TravelAlbumOTGPhotoRecord]) {
|
||||
records.forEach { record in
|
||||
guard record.autoRetouchState == .completed,
|
||||
let materialId = record.materialId,
|
||||
let templateID = record.autoRetouchTemplateId,
|
||||
let resultURL = storage.absoluteURL(for: record.retouchedPath, albumId: albumId),
|
||||
let resultData = try? Data(contentsOf: resultURL) else {
|
||||
return
|
||||
}
|
||||
_ = aiEditResultStore.completeFromImageData(
|
||||
materialID: materialId,
|
||||
presetID: templateID,
|
||||
editedImageData: resultData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncServerUploadStatuses() {
|
||||
serverStatusSyncTask?.cancel()
|
||||
guard albumId > 0 else { return }
|
||||
@@ -593,6 +661,8 @@ final class WiredCameraTransferViewModel {
|
||||
}
|
||||
|
||||
private func uploadPhoto(id: String) async {
|
||||
// 每张照片开始上传时固定一次配置,避免修图过程中切换模板导致结果不一致。
|
||||
let retouchConfiguration = autoRetouchConfiguration
|
||||
do {
|
||||
let record = try await localRecordForUpload(id: id)
|
||||
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
|
||||
@@ -602,13 +672,89 @@ final class WiredCameraTransferViewModel {
|
||||
) { [weak self] progress in
|
||||
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
|
||||
}
|
||||
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
|
||||
// 上传期间用户可能删除照片,删除后不再回写本地任务或发起修图。
|
||||
guard persistedRecordsById[id] != nil else { return }
|
||||
updateRecord(
|
||||
id: id,
|
||||
status: retouchConfiguration.template == nil ? .uploaded : .uploading,
|
||||
progress: 100,
|
||||
error: nil,
|
||||
remoteUrl: material.fileUrl,
|
||||
materialId: material.id
|
||||
)
|
||||
guard let preset = retouchConfiguration.template else { return }
|
||||
await processAutoRetouch(
|
||||
photoId: id,
|
||||
sourceRecord: record,
|
||||
materialId: material.id,
|
||||
preset: preset
|
||||
)
|
||||
} catch {
|
||||
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
|
||||
showMessage(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 对已完成原图上传的照片执行本地演示修图,修图失败不回退上传成功状态。
|
||||
private func processAutoRetouch(
|
||||
photoId: String,
|
||||
sourceRecord: TravelAlbumOTGPhotoRecord,
|
||||
materialId: Int,
|
||||
preset: TravelAlbumEditPreset
|
||||
) async {
|
||||
let resolvedRecord = storage.recordWithResolvedFilePaths(sourceRecord, albumId: albumId)
|
||||
let sourceURL = URL(fileURLWithPath: resolvedRecord.localPath)
|
||||
updateAutoRetouchRecord(
|
||||
id: photoId,
|
||||
state: .processing,
|
||||
templateId: preset.id,
|
||||
materialId: materialId,
|
||||
status: .uploading,
|
||||
error: nil
|
||||
)
|
||||
aiEditResultStore.startProcessing(materialIDs: [materialId])
|
||||
|
||||
do {
|
||||
let resultData = try await autoRetouchProcessor.process(sourceURL: sourceURL, preset: preset)
|
||||
guard persistedRecordsById[photoId] != nil else {
|
||||
aiEditResultStore.remove(materialID: materialId)
|
||||
return
|
||||
}
|
||||
let resultURL = try storage.writeRetouchedImage(
|
||||
resultData,
|
||||
filename: sourceRecord.fileName,
|
||||
albumId: albumId
|
||||
)
|
||||
guard aiEditResultStore.completeFromImageData(
|
||||
materialID: materialId,
|
||||
presetID: preset.id,
|
||||
editedImageData: resultData
|
||||
) else {
|
||||
throw TravelAlbumAutoRetouchError.invalidImageData
|
||||
}
|
||||
updateAutoRetouchRecord(
|
||||
id: photoId,
|
||||
state: .completed,
|
||||
templateId: preset.id,
|
||||
materialId: materialId,
|
||||
status: .uploaded,
|
||||
error: nil,
|
||||
retouchedPath: storage.relativePath(for: resultURL, albumId: albumId)
|
||||
)
|
||||
} catch {
|
||||
aiEditResultStore.failProcessing(materialID: materialId)
|
||||
updateAutoRetouchRecord(
|
||||
id: photoId,
|
||||
state: .failed,
|
||||
templateId: preset.id,
|
||||
materialId: materialId,
|
||||
status: .uploaded,
|
||||
error: error.localizedDescription
|
||||
)
|
||||
showMessage("修图失败,可点击更多重试")
|
||||
}
|
||||
}
|
||||
|
||||
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
|
||||
if let record = persistedRecordsById[id],
|
||||
!record.localPath.isEmpty,
|
||||
@@ -623,7 +769,8 @@ final class WiredCameraTransferViewModel {
|
||||
status: TravelAlbumOTGUploadStatus,
|
||||
progress: Int,
|
||||
error: String?,
|
||||
remoteUrl: String? = nil
|
||||
remoteUrl: String? = nil,
|
||||
materialId: Int? = nil
|
||||
) {
|
||||
var record = persistedRecordsById[id]
|
||||
if record == nil, let item = photos.first(where: { $0.id == id }) {
|
||||
@@ -642,7 +789,10 @@ final class WiredCameraTransferViewModel {
|
||||
errorMessage: error,
|
||||
albumId: albumId,
|
||||
userId: appStore.session.userId,
|
||||
remoteUrl: remoteUrl ?? item.remoteUrl
|
||||
remoteUrl: remoteUrl ?? item.remoteUrl,
|
||||
materialId: materialId,
|
||||
autoRetouchState: item.autoRetouchState,
|
||||
autoRetouchTemplateId: item.autoRetouchTemplateId
|
||||
)
|
||||
}
|
||||
guard var record else { return }
|
||||
@@ -652,6 +802,35 @@ final class WiredCameraTransferViewModel {
|
||||
if let remoteUrl {
|
||||
record.remoteUrl = remoteUrl
|
||||
}
|
||||
if let materialId {
|
||||
record.materialId = materialId
|
||||
}
|
||||
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
persistedRecordsById[id] = record
|
||||
storage.upsert(record, albumId: albumId)
|
||||
applyMergedPhotos()
|
||||
}
|
||||
|
||||
/// 单独更新修图阶段,保留已完成的原图上传信息。
|
||||
private func updateAutoRetouchRecord(
|
||||
id: String,
|
||||
state: TravelAlbumAutoRetouchState,
|
||||
templateId: String,
|
||||
materialId: Int,
|
||||
status: TravelAlbumOTGUploadStatus,
|
||||
error: String?,
|
||||
retouchedPath: String? = nil
|
||||
) {
|
||||
guard var record = persistedRecordsById[id] else { return }
|
||||
record.status = status
|
||||
record.progress = 100
|
||||
record.errorMessage = error
|
||||
record.materialId = materialId
|
||||
record.autoRetouchState = state
|
||||
record.autoRetouchTemplateId = templateId
|
||||
if let retouchedPath {
|
||||
record.retouchedPath = retouchedPath
|
||||
}
|
||||
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
persistedRecordsById[id] = record
|
||||
storage.upsert(record, albumId: albumId)
|
||||
|
||||
@@ -8,6 +8,7 @@ import UIKit
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
private var sessionExpiredDialog: SessionExpiredDialogViewController?
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
@@ -18,8 +19,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
AppNavigationBarAppearance.applyGlobalAppearance()
|
||||
|
||||
let debugPreviewRoot = makeAccountDeletionDebugPreviewRootIfNeeded()
|
||||
if debugPreviewRoot == nil {
|
||||
enforceAccountDeletionStateBeforeLaunch()
|
||||
}
|
||||
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
window.rootViewController = AppRouter.makeRootViewController()
|
||||
window.rootViewController = debugPreviewRoot ?? AppRouter.makeRootViewController()
|
||||
window.makeKeyAndVisible()
|
||||
self.window = window
|
||||
(UIApplication.shared.delegate as? AppDelegate)?.window = window
|
||||
@@ -85,19 +91,61 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
/// 为设计核对提供仅 Debug 生效的注销页直达入口,不影响正式启动流程。
|
||||
private func makeAccountDeletionDebugPreviewRootIfNeeded() -> UIViewController? {
|
||||
#if DEBUG
|
||||
guard ProcessInfo.processInfo.arguments.contains("--account-deletion-preview") else { return nil }
|
||||
let service = AccountDeletionMockService(responseDelayNanoseconds: 0)
|
||||
let viewModel = AccountDeletionViewModel(username: "18651857230", service: service)
|
||||
return UINavigationController(rootViewController: AccountDeletionViewController(viewModel: viewModel))
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
/// App 冷启动时若账号已进入注销冷静期,则清除旧登录态并返回登录页。
|
||||
private func enforceAccountDeletionStateBeforeLaunch() {
|
||||
guard AppStore.shared.session.isLoggedIn,
|
||||
let username = AccountDeletionIdentity.currentUsername() else { return }
|
||||
switch AccountDeletionMockService.shared.loginState(for: username) {
|
||||
case .pending, .completed:
|
||||
AppStore.shared.logout()
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleSessionDidExpire() {
|
||||
guard AppStore.shared.session.isLoggedIn else { return }
|
||||
guard sessionExpiredDialog == nil else { return }
|
||||
|
||||
GlobalLoadingManager.shared.hideAll()
|
||||
let dialog = SessionExpiredDialogViewController { [weak self] in
|
||||
self?.transitionToLogin()
|
||||
}
|
||||
sessionExpiredDialog = dialog
|
||||
|
||||
guard let presenter = topViewController(from: window?.rootViewController) else {
|
||||
sessionExpiredDialog = nil
|
||||
return
|
||||
}
|
||||
presenter.present(dialog, animated: true)
|
||||
}
|
||||
|
||||
private func transitionToLogin() {
|
||||
sessionExpiredDialog = nil
|
||||
PushNotificationManager.shared.handleLogout()
|
||||
AppStore.shared.logout()
|
||||
AppRouter.setRoot(.login, on: window)
|
||||
}
|
||||
|
||||
@objc private func handleUserDidLogout() {
|
||||
PushNotificationManager.shared.handleLogout()
|
||||
AppStore.shared.logout()
|
||||
AppRouter.setRoot(.login, on: window)
|
||||
transitionToLogin()
|
||||
}
|
||||
|
||||
@objc private func handleUserDidLogin() {
|
||||
sessionExpiredDialog?.dismiss(animated: false)
|
||||
sessionExpiredDialog = nil
|
||||
AppRouter.setRoot(.mainTab, on: window)
|
||||
DispatchQueue.main.async {
|
||||
PushNotificationManager.shared.handleLoginCompleted()
|
||||
@@ -108,4 +156,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
@objc private func handleAccountDidSwitch() {
|
||||
PushNotificationManager.shared.handleAccountSwitched()
|
||||
}
|
||||
|
||||
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
|
||||
guard let viewController else { return nil }
|
||||
if let presented = viewController.presentedViewController {
|
||||
return topViewController(from: presented)
|
||||
}
|
||||
if let navigationController = viewController as? UINavigationController {
|
||||
return topViewController(from: navigationController.visibleViewController)
|
||||
}
|
||||
if let tabBarController = viewController as? UITabBarController {
|
||||
return topViewController(from: tabBarController.selectedViewController)
|
||||
}
|
||||
return viewController
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// SessionExpiredDialogViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 登录凭证失效时展示的强制确认弹窗,用户确认后由调用方执行退出登录。
|
||||
@MainActor
|
||||
final class SessionExpiredDialogViewController: UIViewController {
|
||||
|
||||
private let onConfirm: () -> Void
|
||||
private let confirmButton = AppButton(title: "确认并退出登录")
|
||||
private var didConfirm = false
|
||||
|
||||
/// 创建不可通过点击遮罩关闭的登录失效弹窗。
|
||||
init(onConfirm: @escaping () -> Void) {
|
||||
self.onConfirm = onConfirm
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
isModalInPresentation = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
||||
view.accessibilityViewIsModal = true
|
||||
view.accessibilityIdentifier = "sessionExpired.dialog"
|
||||
|
||||
let cardView = UIView()
|
||||
cardView.backgroundColor = AppColor.cardBackground
|
||||
cardView.layer.cornerRadius = AppRadius.lg
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
let warningImageView = UIImageView(
|
||||
image: UIImage(systemName: "exclamationmark.circle.fill")
|
||||
)
|
||||
warningImageView.tintColor = UIColor(hex: 0xF05A5A)
|
||||
warningImageView.contentMode = .scaleAspectFit
|
||||
warningImageView.isAccessibilityElement = true
|
||||
warningImageView.accessibilityLabel = "登录状态异常"
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "登录提醒"
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.text = "当前用户已在其他设备登录,\n请重新登录"
|
||||
messageLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
messageLabel.font = .systemFont(ofSize: 14)
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.numberOfLines = 0
|
||||
|
||||
confirmButton.accessibilityIdentifier = "sessionExpired.confirmButton"
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
|
||||
let contentStack = UIStackView(arrangedSubviews: [
|
||||
warningImageView,
|
||||
titleLabel,
|
||||
messageLabel,
|
||||
confirmButton,
|
||||
])
|
||||
contentStack.axis = .vertical
|
||||
contentStack.alignment = .fill
|
||||
contentStack.spacing = 16
|
||||
contentStack.setCustomSpacing(4, after: titleLabel)
|
||||
contentStack.setCustomSpacing(20, after: messageLabel)
|
||||
|
||||
view.addSubview(cardView)
|
||||
cardView.addSubview(contentStack)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(24)
|
||||
}
|
||||
warningImageView.snp.makeConstraints { make in
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard !didConfirm else { return }
|
||||
didConfirm = true
|
||||
confirmButton.isEnabled = false
|
||||
onConfirm()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//
|
||||
// CommissionRateLogViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 获客员分成比例修改记录页,以时间轴形式展示历史变更。
|
||||
final class CommissionRateLogViewController: BaseViewController {
|
||||
private let viewModel: CommissionRateLogViewModel
|
||||
private let orderAPI: OrderAPI
|
||||
private let navigationTitle: String
|
||||
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyLabel = UILabel()
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let footerLabel = UILabel()
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, CommissionRateLogEntity>!
|
||||
|
||||
init(saleUserId: Int, acquirerName: String, orderAPI: OrderAPI? = nil) {
|
||||
viewModel = CommissionRateLogViewModel(saleUserId: saleUserId)
|
||||
let trimmedName = acquirerName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
navigationTitle = trimmedName.isEmpty ? "修改记录" : "给\(trimmedName)的分成记录"
|
||||
self.orderAPI = orderAPI ?? NetworkServices.shared.orderAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.refresh(api: orderAPI) }
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = navigationTitle
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
|
||||
tableView.backgroundColor = AppColor.pageBackground
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 132
|
||||
tableView.delegate = self
|
||||
tableView.contentInset.top = AppSpacing.sm
|
||||
tableView.register(
|
||||
CommissionRateLogTimelineCell.self,
|
||||
forCellReuseIdentifier: CommissionRateLogTimelineCell.reuseIdentifier
|
||||
)
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Int, CommissionRateLogEntity>(
|
||||
tableView: tableView
|
||||
) { tableView, indexPath, item in
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: CommissionRateLogTimelineCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? CommissionRateLogTimelineCell else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
cell.configure(with: item, isLatest: indexPath.item == 0)
|
||||
return cell
|
||||
}
|
||||
|
||||
emptyLabel.text = "暂无修改记录"
|
||||
emptyLabel.font = .systemFont(ofSize: 14)
|
||||
emptyLabel.textColor = AppColor.textTertiary
|
||||
emptyLabel.textAlignment = .center
|
||||
emptyLabel.isHidden = true
|
||||
|
||||
loadingIndicator.color = AppColor.primary
|
||||
|
||||
footerLabel.font = .systemFont(ofSize: 13)
|
||||
footerLabel.textColor = AppColor.textTertiary
|
||||
footerLabel.textAlignment = .center
|
||||
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyLabel)
|
||||
view.addSubview(loadingIndicator)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
}
|
||||
loadingIndicator.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyState() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
private func applyState() {
|
||||
if !viewModel.isRefreshing {
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
}
|
||||
if viewModel.initialLoading && viewModel.items.isEmpty {
|
||||
loadingIndicator.startAnimating()
|
||||
} else {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
emptyLabel.isHidden = viewModel.initialLoading || viewModel.isRefreshing || !viewModel.items.isEmpty
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, CommissionRateLogEntity>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.items)
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
|
||||
guard !viewModel.items.isEmpty else {
|
||||
tableView.tableFooterView = UIView()
|
||||
return
|
||||
}
|
||||
footerLabel.text = viewModel.isLoadingMore ? "加载中…" : (viewModel.canLoadMore ? "" : "没有更多")
|
||||
footerLabel.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 52)
|
||||
tableView.tableFooterView = footerLabel
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task { await viewModel.refresh(api: orderAPI) }
|
||||
}
|
||||
}
|
||||
|
||||
extension CommissionRateLogViewController: UITableViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard !viewModel.items.isEmpty,
|
||||
scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height - 180 else {
|
||||
return
|
||||
}
|
||||
Task { await viewModel.loadMore(api: orderAPI) }
|
||||
}
|
||||
}
|
||||
|
||||
/// 分成比例修改记录的时间轴 Cell。
|
||||
final class CommissionRateLogTimelineCell: UITableViewCell {
|
||||
static let reuseIdentifier = "CommissionRateLogTimelineCell"
|
||||
|
||||
private let railView = UIView()
|
||||
private let dotView = UIView()
|
||||
private let cardView = UIView()
|
||||
private let metadataLabel = UILabel()
|
||||
private let latestBadgeLabel = UILabel()
|
||||
private let beforeRateLabel = UILabel()
|
||||
private let arrowImageView = UIImageView(image: UIImage(systemName: "arrow.right"))
|
||||
private let afterRateLabel = UILabel()
|
||||
private let remarkLabel = UILabel()
|
||||
private let contentStack = UIStackView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = AppColor.pageBackground
|
||||
contentView.backgroundColor = AppColor.pageBackground
|
||||
|
||||
railView.backgroundColor = AppColor.primary.withAlphaComponent(0.16)
|
||||
dotView.backgroundColor = AppColor.primary
|
||||
dotView.layer.cornerRadius = 4
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = AppRadius.md
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.04
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
|
||||
cardView.layer.shadowRadius = 2
|
||||
|
||||
metadataLabel.font = .systemFont(ofSize: 12)
|
||||
metadataLabel.textColor = AppColor.textSecondary
|
||||
metadataLabel.numberOfLines = 2
|
||||
|
||||
latestBadgeLabel.text = "最新"
|
||||
latestBadgeLabel.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
latestBadgeLabel.textColor = AppColor.primary
|
||||
latestBadgeLabel.textAlignment = .center
|
||||
latestBadgeLabel.backgroundColor = AppColor.primary.withAlphaComponent(0.10)
|
||||
latestBadgeLabel.layer.cornerRadius = 9
|
||||
latestBadgeLabel.clipsToBounds = true
|
||||
latestBadgeLabel.isHidden = true
|
||||
latestBadgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
latestBadgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
latestBadgeLabel.snp.makeConstraints { make in
|
||||
make.width.equalTo(38)
|
||||
make.height.equalTo(18)
|
||||
}
|
||||
|
||||
let headerStack = UIStackView(arrangedSubviews: [metadataLabel, latestBadgeLabel])
|
||||
headerStack.axis = .horizontal
|
||||
headerStack.alignment = .top
|
||||
headerStack.spacing = AppSpacing.sm
|
||||
|
||||
[beforeRateLabel, afterRateLabel].forEach { label in
|
||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
label.textColor = AppColor.textPrimary
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
}
|
||||
afterRateLabel.textColor = AppColor.primary
|
||||
arrowImageView.tintColor = AppColor.textTertiary
|
||||
arrowImageView.contentMode = .scaleAspectFit
|
||||
|
||||
let rateStack = UIStackView(arrangedSubviews: [beforeRateLabel, arrowImageView, afterRateLabel, UIView()])
|
||||
rateStack.axis = .horizontal
|
||||
rateStack.alignment = .center
|
||||
rateStack.spacing = AppSpacing.sm
|
||||
arrowImageView.snp.makeConstraints { make in
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
|
||||
remarkLabel.font = .systemFont(ofSize: 13)
|
||||
remarkLabel.textColor = AppColor.textSecondary
|
||||
remarkLabel.numberOfLines = 0
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = AppSpacing.sm
|
||||
contentStack.addArrangedSubview(headerStack)
|
||||
contentStack.addArrangedSubview(rateStack)
|
||||
contentStack.addArrangedSubview(remarkLabel)
|
||||
|
||||
contentView.addSubview(railView)
|
||||
contentView.addSubview(dotView)
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(contentStack)
|
||||
|
||||
railView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(24)
|
||||
make.top.bottom.equalToSuperview()
|
||||
make.width.equalTo(1.5)
|
||||
}
|
||||
dotView.snp.makeConstraints { make in
|
||||
make.centerX.equalTo(railView)
|
||||
make.top.equalToSuperview().offset(20)
|
||||
make.size.equalTo(8)
|
||||
}
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.leading.equalTo(railView.snp.trailing).offset(16)
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.top.equalToSuperview().offset(4)
|
||||
make.bottom.equalToSuperview().inset(8)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(15)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 绑定比例变化、时间及可选备注。
|
||||
func configure(with item: CommissionRateLogEntity, isLatest: Bool) {
|
||||
metadataLabel.text = item.metadataLine
|
||||
latestBadgeLabel.isHidden = !isLatest
|
||||
beforeRateLabel.text = item.displayBeforeRate
|
||||
afterRateLabel.text = item.displayAfterRate
|
||||
if let remark = item.displayRemark {
|
||||
remarkLabel.text = "备注:\(remark)"
|
||||
remarkLabel.isHidden = false
|
||||
} else {
|
||||
remarkLabel.text = nil
|
||||
remarkLabel.isHidden = true
|
||||
}
|
||||
accessibilityLabel = [
|
||||
isLatest ? "最新记录" : nil,
|
||||
item.metadataLine,
|
||||
"\(item.displayBeforeRate)修改为\(item.displayAfterRate)",
|
||||
item.displayRemark.map { "备注\($0)" },
|
||||
].compactMap { $0 }.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,13 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF6F7FA)
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 142
|
||||
tableView.contentInset.top = AppSpacing.sm
|
||||
tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm
|
||||
tableView.estimatedRowHeight = 224
|
||||
tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
|
||||
tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier)
|
||||
@@ -245,6 +246,17 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
|
||||
cell.onEditCommission = { [weak self] in
|
||||
self?.presentCommissionDialog(for: acquirer)
|
||||
}
|
||||
cell.onViewCommissionLogs = { [weak self] in
|
||||
guard acquirer.saleUserId > 0 else {
|
||||
self?.showToast("获客员信息无效")
|
||||
return
|
||||
}
|
||||
let controller = CommissionRateLogViewController(
|
||||
saleUserId: acquirer.saleUserId,
|
||||
acquirerName: acquirer.displayName
|
||||
)
|
||||
self?.navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
cell.onCall = {
|
||||
let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return }
|
||||
|
||||
@@ -445,22 +445,25 @@ final class CooperationAcquirerCell: UITableViewCell {
|
||||
|
||||
var onEditRemark: (() -> Void)?
|
||||
var onEditCommission: (() -> Void)?
|
||||
var onViewCommissionLogs: (() -> Void)?
|
||||
var onCall: (() -> Void)?
|
||||
|
||||
private let cardView = UIView()
|
||||
private let avatarContainer = UIView()
|
||||
private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill"))
|
||||
private let nameTitleLabel = UILabel()
|
||||
private let nameValueLabel = UILabel()
|
||||
private let editRemarkButton = UIButton(type: .system)
|
||||
private let phoneTitleLabel = UILabel()
|
||||
private let phoneValueLabel = UILabel()
|
||||
private let callButton = UIButton(type: .system)
|
||||
private let bindTimeTitleLabel = UILabel()
|
||||
private let bindTimeValueLabel = UILabel()
|
||||
private let commissionContainer = UIView()
|
||||
private let commissionTitleLabel = UILabel()
|
||||
private let commissionValueLabel = UILabel()
|
||||
private let bindTimeIconView = UIImageView(image: UIImage(systemName: "calendar"))
|
||||
private let bindTimeValueLabel = UILabel()
|
||||
private let dividerView = UIView()
|
||||
private let editRemarkButton = UIButton(type: .system)
|
||||
private let editCommissionButton = UIButton(type: .system)
|
||||
private let commissionLogButton = UIButton(type: .system)
|
||||
private let actionStack = UIStackView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
@@ -469,135 +472,180 @@ final class CooperationAcquirerCell: UITableViewCell {
|
||||
contentView.backgroundColor = .clear
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = AppRadius.md
|
||||
cardView.layer.cornerRadius = AppRadius.xl
|
||||
cardView.layer.borderWidth = 0.5
|
||||
cardView.layer.borderColor = AppColor.cardOutline.cgColor
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.04
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
|
||||
cardView.layer.shadowRadius = 2
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
cardView.layer.shadowRadius = 8
|
||||
cardView.accessibilityIdentifier = "cooperation_acquirer_card"
|
||||
|
||||
avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
|
||||
avatarContainer.layer.cornerRadius = AppRadius.sm
|
||||
avatarContainer.layer.cornerRadius = 24
|
||||
avatarContainer.clipsToBounds = true
|
||||
|
||||
avatarIconView.tintColor = AppColor.primary
|
||||
avatarIconView.contentMode = .scaleAspectFit
|
||||
|
||||
configureTitleLabel(nameTitleLabel, text: "获客员名称")
|
||||
configureTitleLabel(phoneTitleLabel, text: "手机号")
|
||||
configureTitleLabel(bindTimeTitleLabel, text: "绑定时间")
|
||||
configureTitleLabel(commissionTitleLabel, text: "分成比例")
|
||||
|
||||
nameValueLabel.font = .systemFont(ofSize: 15, weight: .bold)
|
||||
nameValueLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
nameValueLabel.textColor = AppColor.textPrimary
|
||||
nameValueLabel.numberOfLines = 1
|
||||
nameValueLabel.lineBreakMode = .byTruncatingTail
|
||||
nameValueLabel.accessibilityIdentifier = "cooperation_acquirer_name"
|
||||
|
||||
[phoneValueLabel, bindTimeValueLabel, commissionValueLabel].forEach { label in
|
||||
label.font = .systemFont(ofSize: 13)
|
||||
label.textColor = AppColor.textSecondary
|
||||
label.numberOfLines = 1
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
}
|
||||
commissionValueLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
|
||||
editRemarkButton.setTitle("修改备注", for: .normal)
|
||||
editRemarkButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
editRemarkButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
editRemarkButton.setConfigurationContentInsets(
|
||||
NSDirectionalEdgeInsets(top: 16, leading: 14, bottom: 16, trailing: 14)
|
||||
)
|
||||
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
|
||||
phoneValueLabel.font = .systemFont(ofSize: 14)
|
||||
phoneValueLabel.textColor = AppColor.textTertiary
|
||||
phoneValueLabel.numberOfLines = 1
|
||||
phoneValueLabel.lineBreakMode = .byTruncatingTail
|
||||
phoneValueLabel.accessibilityIdentifier = "cooperation_acquirer_phone"
|
||||
|
||||
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||
callButton.tintColor = AppColor.primary
|
||||
callButton.backgroundColor = AppColor.primaryLight
|
||||
callButton.layer.cornerRadius = 15
|
||||
callButton.layer.cornerRadius = AppSpacing.minTouchTarget / 2
|
||||
callButton.layer.borderWidth = 0.5
|
||||
callButton.layer.borderColor = AppColor.primary.withAlphaComponent(0.12).cgColor
|
||||
callButton.imageView?.contentMode = .scaleAspectFit
|
||||
callButton.accessibilityLabel = "拨打获客员电话"
|
||||
callButton.accessibilityIdentifier = "cooperation_acquirer_call"
|
||||
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
|
||||
|
||||
editCommissionButton.setTitle("修改比例", for: .normal)
|
||||
editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13)
|
||||
editCommissionButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
editCommissionButton.setConfigurationContentInsets(
|
||||
NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)
|
||||
commissionContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
|
||||
commissionContainer.layer.cornerRadius = AppRadius.lg
|
||||
|
||||
commissionTitleLabel.text = "分成比例"
|
||||
commissionTitleLabel.font = .systemFont(ofSize: 12)
|
||||
commissionTitleLabel.textColor = AppColor.textTertiary
|
||||
|
||||
commissionValueLabel.font = .systemFont(ofSize: 24, weight: .bold)
|
||||
commissionValueLabel.textColor = AppColor.primary
|
||||
commissionValueLabel.numberOfLines = 1
|
||||
commissionValueLabel.adjustsFontSizeToFitWidth = true
|
||||
commissionValueLabel.minimumScaleFactor = 0.75
|
||||
commissionValueLabel.accessibilityIdentifier = "cooperation_acquirer_commission_rate"
|
||||
|
||||
bindTimeIconView.tintColor = AppColor.textTertiary
|
||||
bindTimeIconView.contentMode = .scaleAspectFit
|
||||
|
||||
bindTimeValueLabel.font = .systemFont(ofSize: 13)
|
||||
bindTimeValueLabel.textColor = AppColor.textTertiary
|
||||
bindTimeValueLabel.numberOfLines = 2
|
||||
bindTimeValueLabel.lineBreakMode = .byTruncatingTail
|
||||
bindTimeValueLabel.accessibilityIdentifier = "cooperation_acquirer_bind_time"
|
||||
|
||||
dividerView.backgroundColor = AppColor.cardOutline
|
||||
|
||||
configureActionButton(
|
||||
editRemarkButton,
|
||||
title: "修改备注",
|
||||
isPrimary: false,
|
||||
accessibilityIdentifier: "cooperation_acquirer_edit_remark"
|
||||
)
|
||||
editRemarkButton.addTarget(self, action: #selector(editRemarkTapped), for: .touchUpInside)
|
||||
|
||||
configureActionButton(
|
||||
commissionLogButton,
|
||||
title: "修改记录",
|
||||
isPrimary: false,
|
||||
accessibilityIdentifier: "cooperation_acquirer_commission_logs"
|
||||
)
|
||||
commissionLogButton.accessibilityLabel = "查看分成比例修改记录"
|
||||
commissionLogButton.addTarget(self, action: #selector(commissionLogTapped), for: .touchUpInside)
|
||||
|
||||
configureActionButton(
|
||||
editCommissionButton,
|
||||
title: "修改比例",
|
||||
isPrimary: true,
|
||||
accessibilityIdentifier: "cooperation_acquirer_edit_commission"
|
||||
)
|
||||
editCommissionButton.addTarget(self, action: #selector(editCommissionTapped), for: .touchUpInside)
|
||||
|
||||
actionStack.axis = .horizontal
|
||||
actionStack.alignment = .fill
|
||||
actionStack.distribution = .fillEqually
|
||||
actionStack.spacing = AppSpacing.xs
|
||||
actionStack.addArrangedSubview(editRemarkButton)
|
||||
actionStack.addArrangedSubview(commissionLogButton)
|
||||
actionStack.addArrangedSubview(editCommissionButton)
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(avatarContainer)
|
||||
avatarContainer.addSubview(avatarIconView)
|
||||
cardView.addSubview(nameTitleLabel)
|
||||
cardView.addSubview(nameValueLabel)
|
||||
cardView.addSubview(editRemarkButton)
|
||||
cardView.addSubview(phoneTitleLabel)
|
||||
cardView.addSubview(phoneValueLabel)
|
||||
cardView.addSubview(callButton)
|
||||
cardView.addSubview(bindTimeTitleLabel)
|
||||
cardView.addSubview(commissionContainer)
|
||||
commissionContainer.addSubview(commissionTitleLabel)
|
||||
commissionContainer.addSubview(commissionValueLabel)
|
||||
cardView.addSubview(bindTimeIconView)
|
||||
cardView.addSubview(bindTimeValueLabel)
|
||||
cardView.addSubview(commissionTitleLabel)
|
||||
cardView.addSubview(commissionValueLabel)
|
||||
cardView.addSubview(editCommissionButton)
|
||||
cardView.addSubview(dividerView)
|
||||
cardView.addSubview(actionStack)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 14, bottom: AppSpacing.sm, right: 14))
|
||||
make.edges.equalToSuperview().inset(
|
||||
UIEdgeInsets(top: 0, left: AppSpacing.md, bottom: AppSpacing.sm, right: AppSpacing.md)
|
||||
)
|
||||
}
|
||||
avatarContainer.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(14)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(44)
|
||||
make.top.leading.equalToSuperview().inset(AppSpacing.md)
|
||||
make.size.equalTo(48)
|
||||
}
|
||||
avatarIconView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(22)
|
||||
}
|
||||
nameTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(AppSpacing.md)
|
||||
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
nameValueLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(nameTitleLabel)
|
||||
make.leading.equalTo(nameTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualTo(editRemarkButton.snp.leading).offset(-AppSpacing.xs)
|
||||
}
|
||||
editRemarkButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview()
|
||||
}
|
||||
phoneTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.equalTo(nameTitleLabel)
|
||||
make.top.equalTo(avatarContainer).offset(2)
|
||||
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
|
||||
}
|
||||
phoneValueLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(phoneTitleLabel)
|
||||
make.leading.equalTo(phoneTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.xs)
|
||||
make.top.equalTo(nameValueLabel.snp.bottom).offset(AppSpacing.xxs)
|
||||
make.leading.equalTo(nameValueLabel)
|
||||
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
|
||||
}
|
||||
callButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.centerY.equalTo(phoneTitleLabel)
|
||||
make.size.equalTo(30)
|
||||
make.top.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.size.equalTo(AppSpacing.minTouchTarget)
|
||||
}
|
||||
bindTimeTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(phoneTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.equalTo(nameTitleLabel)
|
||||
}
|
||||
bindTimeValueLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(bindTimeTitleLabel)
|
||||
make.leading.equalTo(bindTimeTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||
commissionContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(avatarContainer.snp.bottom).offset(14)
|
||||
make.leading.equalToSuperview().inset(AppSpacing.md)
|
||||
make.width.equalTo(120)
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
commissionTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(bindTimeTitleLabel.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.equalTo(nameTitleLabel)
|
||||
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.top.equalToSuperview().offset(AppSpacing.xs)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
commissionValueLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(commissionTitleLabel)
|
||||
make.leading.equalTo(commissionTitleLabel.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualTo(editCommissionButton.snp.leading).offset(-AppSpacing.xs)
|
||||
make.top.equalTo(commissionTitleLabel.snp.bottom).offset(2)
|
||||
make.centerX.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualToSuperview().offset(AppSpacing.xs)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.xs)
|
||||
}
|
||||
editCommissionButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(commissionTitleLabel)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.xs)
|
||||
bindTimeIconView.snp.makeConstraints { make in
|
||||
make.leading.equalTo(commissionContainer.snp.trailing).offset(AppSpacing.md)
|
||||
make.centerY.equalTo(commissionContainer)
|
||||
make.size.equalTo(18)
|
||||
}
|
||||
bindTimeValueLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(bindTimeIconView.snp.trailing).offset(AppSpacing.xs)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.centerY.equalTo(bindTimeIconView)
|
||||
}
|
||||
dividerView.snp.makeConstraints { make in
|
||||
make.top.equalTo(commissionContainer.snp.bottom).offset(14)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
actionStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(dividerView.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(AppSpacing.minTouchTarget)
|
||||
make.bottom.equalToSuperview().inset(14)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,26 +658,52 @@ final class CooperationAcquirerCell: UITableViewCell {
|
||||
super.prepareForReuse()
|
||||
onEditRemark = nil
|
||||
onEditCommission = nil
|
||||
onViewCommissionLogs = nil
|
||||
onCall = nil
|
||||
}
|
||||
|
||||
func configure(with acquirer: CooperativeSalerEntity) {
|
||||
nameValueLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName
|
||||
phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "—" : CooperationOrderPhoneMask.mask(acquirer.displayPhone)
|
||||
bindTimeValueLabel.text = acquirer.displayBindTime.isEmpty ? "—" : acquirer.displayBindTime
|
||||
let bindTime = acquirer.displayBindTime.isEmpty ? "—" : Self.minutePrecisionTime(acquirer.displayBindTime)
|
||||
bindTimeValueLabel.text = "绑定于 \(bindTime)"
|
||||
commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "—" : acquirer.displayCommissionRate
|
||||
}
|
||||
|
||||
private func configureTitleLabel(_ label: UILabel, text: String) {
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 13)
|
||||
label.textColor = AppColor.textTertiary
|
||||
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
private func configureActionButton(
|
||||
_ button: UIButton,
|
||||
title: String,
|
||||
isPrimary: Bool,
|
||||
accessibilityIdentifier: String
|
||||
) {
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.title = title
|
||||
configuration.baseForegroundColor = isPrimary ? AppColor.primary : UIColor(hex: 0x5F6673)
|
||||
configuration.background.backgroundColor = isPrimary ? AppColor.primaryLight : .white
|
||||
configuration.background.strokeColor = isPrimary
|
||||
? AppColor.primary.withAlphaComponent(0.35)
|
||||
: UIColor(hex: 0xD9DEE7)
|
||||
configuration.background.strokeWidth = 1
|
||||
configuration.background.cornerRadius = AppRadius.md
|
||||
configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)
|
||||
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
|
||||
var updated = attributes
|
||||
updated.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
return updated
|
||||
}
|
||||
button.configuration = configuration
|
||||
button.accessibilityIdentifier = accessibilityIdentifier
|
||||
}
|
||||
|
||||
private static func minutePrecisionTime(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count == 19, trimmed.dropFirst(16).first == ":" else { return trimmed }
|
||||
return String(trimmed.prefix(16))
|
||||
}
|
||||
|
||||
@objc private func editRemarkTapped() { onEditRemark?() }
|
||||
@objc private func editCommissionTapped() { onEditCommission?() }
|
||||
@objc private func commissionLogTapped() { onViewCommissionLogs?() }
|
||||
@objc private func callTapped() { onCall?() }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import CoreLocation
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
import MapKit
|
||||
#endif
|
||||
|
||||
/// 地图缩放/定位控件。
|
||||
final class LocationReportMapControlStack: UIView {
|
||||
|
||||
@@ -295,7 +299,112 @@ final class LocationReportBottomActionView: UIControl {
|
||||
}
|
||||
}
|
||||
|
||||
/// 地图容器,封装高德 `MAMapView`。
|
||||
/// 位置上报地图容器,模拟器使用 MapKit。
|
||||
#if targetEnvironment(simulator)
|
||||
final class LocationReportMapView: UIView, MKMapViewDelegate {
|
||||
|
||||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||||
|
||||
let mapView: MKMapView
|
||||
private var markerAnnotation: MKPointAnnotation?
|
||||
private var shouldCenterOnNextUserLocation = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
mapView = MKMapView(frame: .zero)
|
||||
super.init(frame: frame)
|
||||
mapView.delegate = self
|
||||
mapView.showsUserLocation = true
|
||||
mapView.showsCompass = false
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
|
||||
tapGesture.cancelsTouchesInView = false
|
||||
mapView.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 进入页面后,在首次拿到用户位置时将地图居中。
|
||||
func enableInitialUserLocationCentering() {
|
||||
shouldCenterOnNextUserLocation = true
|
||||
centerOnUserLocationIfNeeded()
|
||||
}
|
||||
|
||||
func centerOnUserLocation() {
|
||||
guard let coordinate = validUserCoordinate else { return }
|
||||
setCenter(coordinate)
|
||||
}
|
||||
|
||||
func setCenter(_ coordinate: CLLocationCoordinate2D, animated: Bool = true) {
|
||||
let region = MKCoordinateRegion(
|
||||
center: coordinate,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.012)
|
||||
)
|
||||
mapView.setRegion(region, animated: animated)
|
||||
}
|
||||
|
||||
func zoomIn() {
|
||||
updateZoom(scale: 0.5)
|
||||
}
|
||||
|
||||
func zoomOut() {
|
||||
updateZoom(scale: 2)
|
||||
}
|
||||
|
||||
func updateMarker(latitude: Double?, longitude: Double?) {
|
||||
if let markerAnnotation {
|
||||
mapView.removeAnnotation(markerAnnotation)
|
||||
self.markerAnnotation = nil
|
||||
}
|
||||
guard let latitude, let longitude else { return }
|
||||
let annotation = MKPointAnnotation()
|
||||
annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
markerAnnotation = annotation
|
||||
mapView.addAnnotation(annotation)
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||||
centerOnUserLocationIfNeeded()
|
||||
}
|
||||
|
||||
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard gesture.state == .ended else { return }
|
||||
let point = gesture.location(in: mapView)
|
||||
onMapTap?(mapView.convert(point, toCoordinateFrom: mapView))
|
||||
}
|
||||
|
||||
private var validUserCoordinate: CLLocationCoordinate2D? {
|
||||
guard let coordinate = mapView.userLocation.location?.coordinate,
|
||||
CLLocationCoordinate2DIsValid(coordinate),
|
||||
coordinate.latitude != 0 || coordinate.longitude != 0 else {
|
||||
return nil
|
||||
}
|
||||
return coordinate
|
||||
}
|
||||
|
||||
private func centerOnUserLocationIfNeeded() {
|
||||
guard shouldCenterOnNextUserLocation, let coordinate = validUserCoordinate else { return }
|
||||
setCenter(coordinate, animated: false)
|
||||
shouldCenterOnNextUserLocation = false
|
||||
}
|
||||
|
||||
private func updateZoom(scale: Double) {
|
||||
let current = mapView.region
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: min(max(current.span.latitudeDelta * scale, 0.0005), 120),
|
||||
longitudeDelta: min(max(current.span.longitudeDelta * scale, 0.0005), 120)
|
||||
)
|
||||
mapView.setRegion(MKCoordinateRegion(center: current.center, span: span), animated: true)
|
||||
}
|
||||
}
|
||||
#else
|
||||
/// 位置上报地图容器,真机封装高德 `MAMapView`。
|
||||
final class LocationReportMapView: UIView, MAMapViewDelegate {
|
||||
|
||||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||||
@@ -381,3 +490,4 @@ final class LocationReportMapView: UIView, MAMapViewDelegate {
|
||||
shouldCenterOnNextUserLocation = false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -273,12 +273,53 @@ final class LoginViewController: BaseViewController {
|
||||
|
||||
do {
|
||||
let resolution = try await viewModel.login(authAPI: authAPI)
|
||||
handleLoginResolution(resolution)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLoginResolution(_ resolution: LoginResolution) {
|
||||
switch resolution {
|
||||
case let .completed(response, account):
|
||||
completeLogin(with: response, account: account)
|
||||
case .needsAccountSelection:
|
||||
break
|
||||
case let .accountDeletionPending(response, request):
|
||||
presentAccountDeletionRecovery(response: response, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAccountDeletionRecovery(
|
||||
response: V9AuthResponse,
|
||||
request: AccountDeletionRequest
|
||||
) {
|
||||
let deletionTime = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt)
|
||||
let alert = UIAlertController(
|
||||
title: "账号正在注销中",
|
||||
message: "账号将在 \(deletionTime) 永久注销。继续登录将取消本次注销申请。",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "暂不登录", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "恢复账号并登录", style: .default) { [weak self] _ in
|
||||
self?.resumeLoginAfterCancelingDeletion(response: response)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func resumeLoginAfterCancelingDeletion(response: V9AuthResponse) {
|
||||
Task {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
let resolution = try await viewModel.resumeLoginAfterCancelingDeletion(
|
||||
verifiedResponse: response,
|
||||
authAPI: authAPI
|
||||
)
|
||||
handleLoginResolution(resolution)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
|
||||
@@ -8,6 +8,8 @@ import Foundation
|
||||
/// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。
|
||||
final class LoginViewModel {
|
||||
|
||||
private let accountDeletionService: any AccountDeletionServing
|
||||
|
||||
private(set) var account = ""
|
||||
private(set) var password = ""
|
||||
private(set) var isPrivacyChecked = false
|
||||
@@ -16,6 +18,11 @@ final class LoginViewModel {
|
||||
private(set) var isSelectingAccount = false
|
||||
private(set) var pendingAccountSelection: AccountSelectionPayload?
|
||||
|
||||
/// 创建登录 ViewModel,并注入注销状态服务以支持7天内恢复账号。
|
||||
init(accountDeletionService: any AccountDeletionServing = AccountDeletionMockService.shared) {
|
||||
self.accountDeletionService = accountDeletionService
|
||||
}
|
||||
|
||||
var isLoginEnabled: Bool {
|
||||
isValidPhone && !trimmedPassword.isEmpty
|
||||
}
|
||||
@@ -122,9 +129,27 @@ final class LoginViewModel {
|
||||
username: normalizedUsername,
|
||||
password: trimmedPassword
|
||||
)
|
||||
|
||||
switch accountDeletionService.loginState(for: normalizedUsername) {
|
||||
case let .pending(request):
|
||||
return .accountDeletionPending(response, request)
|
||||
case .completed:
|
||||
throw AccountDeletionError.deletionCompleted
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
return try await resolveLoginResponse(response, authAPI: authAPI)
|
||||
}
|
||||
|
||||
/// 用户确认恢复账号后取消注销,并继续处理已经通过密码验证的登录响应。
|
||||
func resumeLoginAfterCancelingDeletion(
|
||||
verifiedResponse: V9AuthResponse,
|
||||
authAPI: AuthAPI
|
||||
) async throws -> LoginResolution {
|
||||
_ = try accountDeletionService.cancelDeletion(username: normalizedUsername)
|
||||
return try await resolveLoginResponse(verifiedResponse, authAPI: authAPI)
|
||||
}
|
||||
|
||||
/// 选择一个账号并调用 set-user 换取正式 token。
|
||||
func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse {
|
||||
guard let payload = pendingAccountSelection, payload.hasTempToken else {
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
//
|
||||
// OfflineCollectionDailyViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 日清列表的分区。
|
||||
enum OfflineDailyListSection: Hashable {
|
||||
case records
|
||||
case empty
|
||||
}
|
||||
|
||||
/// 日清列表的条目数据。
|
||||
enum OfflineDailyListItem: Hashable {
|
||||
case record(OfflineCollectionRecord)
|
||||
case empty(isToday: Bool)
|
||||
}
|
||||
|
||||
/// 线下收款日清页,展示单营业日汇总、收款明细与补缴操作。
|
||||
final class OfflineCollectionDailyViewController: BaseViewController {
|
||||
private let viewModel: OfflineCollectionDailyViewModel
|
||||
|
||||
private let headerStack = UIStackView()
|
||||
private let dateControl = UIButton(type: .system)
|
||||
private let summaryCard = UIView()
|
||||
private let summaryStatusLabel = UILabel()
|
||||
private let totalMetric = OfflineDailyMetricView(title: "线下收款总额")
|
||||
private let countMetric = OfflineDailyMetricView(title: "登记笔数")
|
||||
private let settledMetric = OfflineDailyMetricView(title: "已补缴")
|
||||
private let pendingMetric = OfflineDailyMetricView(title: "待补缴")
|
||||
private let overdueBanner = UILabel()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private var dataSource: UITableViewDiffableDataSource<OfflineDailyListSection, OfflineDailyListItem>!
|
||||
private var currentSections: [OfflineDailyListSection] = []
|
||||
private let bottomContainer = UIView()
|
||||
private let settlementButton = UIButton(type: .system)
|
||||
private var isFeedbackPresented = false
|
||||
|
||||
/// 使用指定营业日、收款上下文与 Mock 服务创建日清页。
|
||||
init(
|
||||
businessDate: String,
|
||||
context: OfflineCollectionContext = .current(),
|
||||
service: OfflineCollectionMockService = .shared
|
||||
) {
|
||||
viewModel = OfflineCollectionDailyViewModel(
|
||||
businessDate: businessDate,
|
||||
context: context,
|
||||
service: service
|
||||
)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "线下收款日清"
|
||||
#if DEBUG
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "模拟失败",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(simulateFailureTapped)
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
headerStack.axis = .vertical
|
||||
headerStack.spacing = AppSpacing.sm
|
||||
|
||||
dateControl.contentHorizontalAlignment = .leading
|
||||
dateControl.showsMenuAsPrimaryAction = true
|
||||
var dateConfiguration = UIButton.Configuration.plain()
|
||||
dateConfiguration.image = UIImage(systemName: "calendar")
|
||||
dateConfiguration.imagePadding = AppSpacing.sm
|
||||
dateConfiguration.baseForegroundColor = AppColor.textPrimary
|
||||
dateConfiguration.background.backgroundColor = .white
|
||||
dateConfiguration.background.cornerRadius = AppRadius.lg
|
||||
dateConfiguration.contentInsets = NSDirectionalEdgeInsets(
|
||||
top: 12,
|
||||
leading: AppSpacing.md,
|
||||
bottom: 12,
|
||||
trailing: AppSpacing.md
|
||||
)
|
||||
dateControl.configuration = dateConfiguration
|
||||
dateControl.accessibilityIdentifier = "offlineCollection.businessDate"
|
||||
|
||||
summaryCard.backgroundColor = .white
|
||||
summaryCard.layer.cornerRadius = AppRadius.lg
|
||||
summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
summaryStatusLabel.textAlignment = .center
|
||||
summaryStatusLabel.textColor = AppColor.success
|
||||
summaryStatusLabel.backgroundColor = UIColor(hex: 0xECFDF3)
|
||||
summaryStatusLabel.layer.cornerRadius = 12
|
||||
summaryStatusLabel.clipsToBounds = true
|
||||
|
||||
let firstMetricRow = UIStackView(arrangedSubviews: [totalMetric, countMetric])
|
||||
firstMetricRow.axis = .horizontal
|
||||
firstMetricRow.distribution = .fillEqually
|
||||
let secondMetricRow = UIStackView(arrangedSubviews: [settledMetric, pendingMetric])
|
||||
secondMetricRow.axis = .horizontal
|
||||
secondMetricRow.distribution = .fillEqually
|
||||
let metricStack = UIStackView(arrangedSubviews: [firstMetricRow, secondMetricRow])
|
||||
metricStack.axis = .vertical
|
||||
metricStack.spacing = AppSpacing.md
|
||||
summaryCard.addSubview(metricStack)
|
||||
summaryCard.addSubview(summaryStatusLabel)
|
||||
metricStack.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
summaryStatusLabel.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
|
||||
overdueBanner.text = "该营业日存在逾期未补缴,请尽快完成补缴"
|
||||
overdueBanner.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
overdueBanner.textColor = AppColor.danger
|
||||
overdueBanner.backgroundColor = UIColor(hex: 0xFFF1F0)
|
||||
overdueBanner.layer.cornerRadius = AppRadius.md
|
||||
overdueBanner.clipsToBounds = true
|
||||
overdueBanner.textAlignment = .center
|
||||
overdueBanner.numberOfLines = 0
|
||||
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 92
|
||||
tableView.delegate = self
|
||||
tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier)
|
||||
tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier)
|
||||
dataSource = makeDataSource()
|
||||
|
||||
bottomContainer.backgroundColor = .white
|
||||
bottomContainer.layer.shadowColor = UIColor.black.cgColor
|
||||
bottomContainer.layer.shadowOpacity = 0.06
|
||||
bottomContainer.layer.shadowRadius = 8
|
||||
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2)
|
||||
|
||||
var settlementConfiguration = UIButton.Configuration.filled()
|
||||
settlementConfiguration.title = "补缴今日全部"
|
||||
settlementConfiguration.baseBackgroundColor = AppColor.primary
|
||||
settlementConfiguration.baseForegroundColor = .white
|
||||
settlementConfiguration.cornerStyle = .medium
|
||||
settlementConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
return outgoing
|
||||
}
|
||||
settlementButton.configuration = settlementConfiguration
|
||||
settlementButton.accessibilityIdentifier = "offlineCollection.settle"
|
||||
|
||||
view.addSubview(headerStack)
|
||||
[dateControl, summaryCard, overdueBanner].forEach(headerStack.addArrangedSubview)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(bottomContainer)
|
||||
bottomContainer.addSubview(settlementButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
headerStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
}
|
||||
dateControl.snp.makeConstraints { make in
|
||||
make.height.equalTo(50)
|
||||
}
|
||||
summaryCard.snp.makeConstraints { make in
|
||||
make.height.equalTo(172)
|
||||
}
|
||||
overdueBanner.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(48)
|
||||
}
|
||||
bottomContainer.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
settlementButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().inset(AppSpacing.md)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
make.height.equalTo(50)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerStack.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(bottomContainer.snp.top)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
let datePrefix = viewModel.isToday ? "今日 · " : "营业日 · "
|
||||
dateControl.configuration?.title = datePrefix + viewModel.businessDate
|
||||
dateControl.menu = makeDateMenu()
|
||||
|
||||
totalMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.totalAmountFen))
|
||||
countMetric.setValue("\(viewModel.summary.totalCount) 笔")
|
||||
settledMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.settledAmountFen))
|
||||
pendingMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen), highlighted: viewModel.summary.pendingAmountFen > 0)
|
||||
|
||||
let isSettled = viewModel.summary.totalCount > 0 && viewModel.summary.pendingAmountFen == 0
|
||||
summaryStatusLabel.text = " 本日已结清 "
|
||||
summaryStatusLabel.isHidden = !isSettled
|
||||
overdueBanner.isHidden = !viewModel.summary.hasOverdue
|
||||
|
||||
let isProcessing = viewModel.settlementState == .processing
|
||||
settlementButton.isEnabled = viewModel.canSettle
|
||||
var configuration = settlementButton.configuration
|
||||
configuration?.showsActivityIndicator = isProcessing
|
||||
if isProcessing {
|
||||
configuration?.title = "补缴中"
|
||||
} else if isSettled {
|
||||
configuration?.title = "本日已全部补缴"
|
||||
} else if viewModel.summary.pendingAmountFen == 0 {
|
||||
configuration?.title = "当日暂无待补缴"
|
||||
} else {
|
||||
let prefix = viewModel.isToday ? "补缴今日全部" : "补缴本日全部"
|
||||
configuration?.title = "\(prefix) \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))"
|
||||
}
|
||||
settlementButton.configuration = configuration
|
||||
settlementButton.alpha = settlementButton.isEnabled || isProcessing ? 1 : 0.5
|
||||
|
||||
applySnapshot()
|
||||
presentSettlementFeedbackIfNeeded()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applySnapshot() {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<OfflineDailyListSection, OfflineDailyListItem>()
|
||||
currentSections = []
|
||||
if viewModel.records.isEmpty {
|
||||
currentSections.append(.empty)
|
||||
snapshot.appendSections([.empty])
|
||||
snapshot.appendItems([.empty(isToday: viewModel.isToday)], toSection: .empty)
|
||||
} else {
|
||||
currentSections.append(.records)
|
||||
snapshot.appendSections([.records])
|
||||
snapshot.appendItems(viewModel.records.map(OfflineDailyListItem.record), toSection: .records)
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: view.window != nil)
|
||||
}
|
||||
|
||||
private func makeDataSource() -> UITableViewDiffableDataSource<OfflineDailyListSection, OfflineDailyListItem> {
|
||||
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||
switch item {
|
||||
case let .record(record):
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: OfflineCollectionRecordCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? OfflineCollectionRecordCell else { return UITableViewCell() }
|
||||
cell.apply(record: record)
|
||||
return cell
|
||||
case let .empty(isToday):
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? OfflineCollectionEmptyCell else { return UITableViewCell() }
|
||||
cell.apply(isToday: isToday)
|
||||
cell.onRegister = { [weak self] in self?.openRegistration() }
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeDateMenu() -> UIMenu {
|
||||
let actions = viewModel.availableBusinessDates.map { [weak self] date in
|
||||
let isCurrent = date == self?.viewModel.businessDate
|
||||
let title = date == self?.viewModel.service.todayBusinessDate ? "今日 \(date)" : date
|
||||
return UIAction(title: title, state: isCurrent ? .on : .off) { [weak self] _ in
|
||||
self?.viewModel.selectBusinessDate(date)
|
||||
}
|
||||
}
|
||||
return UIMenu(title: "选择营业日", children: actions)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func presentSettlementFeedbackIfNeeded() {
|
||||
guard !isFeedbackPresented else { return }
|
||||
switch viewModel.settlementState {
|
||||
case .idle, .processing:
|
||||
return
|
||||
case let .success(batch):
|
||||
isFeedbackPresented = true
|
||||
let message = """
|
||||
已补缴 \(OfflineCollectionMoney.display(batch.amountFen))
|
||||
共结清 \(batch.recordCount) 笔线下收款
|
||||
补缴流水号:\(batch.id)
|
||||
"""
|
||||
let alert = UIAlertController(title: "补缴成功", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in
|
||||
self?.isFeedbackPresented = false
|
||||
self?.viewModel.clearSettlementFeedback()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
case let .failed(message):
|
||||
isFeedbackPresented = true
|
||||
let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in
|
||||
self?.isFeedbackPresented = false
|
||||
self?.viewModel.cancelPreparedSettlement()
|
||||
self?.viewModel.clearSettlementFeedback()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.isFeedbackPresented = false
|
||||
self.viewModel.clearSettlementFeedback()
|
||||
Task { await self.viewModel.confirmSettlement() }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func openRegistration() {
|
||||
guard viewModel.isToday else { return }
|
||||
navigationController?.pushViewController(
|
||||
OfflineCollectionRegistrationViewController(
|
||||
context: viewModel.context,
|
||||
service: viewModel.service
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func settlementTapped() {
|
||||
do {
|
||||
let request = try viewModel.prepareSettlement()
|
||||
let message = """
|
||||
营业日:\(request.businessDate)
|
||||
待补缴记录:\(request.recordIds.count) 笔
|
||||
本次补缴金额:\(OfflineCollectionMoney.display(request.amountFen))
|
||||
|
||||
补缴成功后,本批次记录将统一变为“已补缴”。
|
||||
"""
|
||||
let alert = UIAlertController(title: "确认补缴", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.cancelPreparedSettlement()
|
||||
})
|
||||
alert.addAction(UIAlertAction(
|
||||
title: "确认补缴 \(OfflineCollectionMoney.display(request.amountFen))",
|
||||
style: .default
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.confirmSettlement() }
|
||||
})
|
||||
present(alert, animated: true)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
viewModel.load()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func simulateFailureTapped() {
|
||||
viewModel.simulateNextSettlementFailure()
|
||||
showToast("下一次补缴将模拟失败,可验收失败后重试")
|
||||
}
|
||||
}
|
||||
|
||||
extension OfflineCollectionDailyViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
currentSections[safe: section] == .empty ? 0 : 40
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
guard let listSection = currentSections[safe: section], listSection != .empty else { return nil }
|
||||
let container = UIView()
|
||||
container.backgroundColor = AppColor.pageBackground
|
||||
let label = UILabel()
|
||||
label.text = "线下收款明细"
|
||||
label.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
label.textColor = AppColor.textPrimary
|
||||
container.addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
make.bottom.equalToSuperview().inset(AppSpacing.xs)
|
||||
}
|
||||
return container
|
||||
}
|
||||
}
|
||||
|
||||
/// 日清汇总卡片中的单项指标视图。
|
||||
final class OfflineDailyMetricView: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let valueLabel = UILabel()
|
||||
|
||||
/// 使用指标名称创建汇总视图。
|
||||
init(title: String) {
|
||||
super.init(frame: .zero)
|
||||
titleLabel.text = title
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新指标值及是否使用警示色强调。
|
||||
func setValue(_ value: String, highlighted: Bool = false) {
|
||||
valueLabel.text = value
|
||||
valueLabel.textColor = highlighted ? UIColor(hex: 0xD97706) : AppColor.textPrimary
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
titleLabel.font = .systemFont(ofSize: 12)
|
||||
titleLabel.textColor = AppColor.textTertiary
|
||||
valueLabel.font = .systemFont(ofSize: 19, weight: .bold)
|
||||
valueLabel.textColor = AppColor.textPrimary
|
||||
valueLabel.adjustsFontSizeToFitWidth = true
|
||||
valueLabel.minimumScaleFactor = 0.75
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 4
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 线下收款明细单元格。
|
||||
final class OfflineCollectionRecordCell: UITableViewCell {
|
||||
static let reuseIdentifier = "OfflineCollectionRecordCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let timeLabel = UILabel()
|
||||
private let methodLabel = UILabel()
|
||||
private let identifierLabel = UILabel()
|
||||
private let amountLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 用线下收款记录刷新单元格。
|
||||
func apply(record: OfflineCollectionRecord) {
|
||||
timeLabel.text = OfflineCollectionDate.timeText(record.registeredAt)
|
||||
methodLabel.text = record.paymentMethod.displayName
|
||||
identifierLabel.text = record.id
|
||||
amountLabel.text = OfflineCollectionMoney.display(record.amountFen)
|
||||
statusLabel.text = " \(record.status.displayName) "
|
||||
|
||||
let color: UIColor
|
||||
let background: UIColor
|
||||
switch record.status {
|
||||
case .pending:
|
||||
color = UIColor(hex: 0xD97706)
|
||||
background = UIColor(hex: 0xFFF7E6)
|
||||
case .settled:
|
||||
color = AppColor.success
|
||||
background = UIColor(hex: 0xECFDF3)
|
||||
case .overdue:
|
||||
color = AppColor.danger
|
||||
background = UIColor(hex: 0xFFF1F0)
|
||||
}
|
||||
statusLabel.textColor = color
|
||||
statusLabel.backgroundColor = background
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = AppRadius.lg
|
||||
|
||||
timeLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
timeLabel.textColor = AppColor.textPrimary
|
||||
methodLabel.font = .systemFont(ofSize: 14)
|
||||
methodLabel.textColor = AppColor.textSecondary
|
||||
identifierLabel.font = .systemFont(ofSize: 11)
|
||||
identifierLabel.textColor = AppColor.textTertiary
|
||||
amountLabel.font = .systemFont(ofSize: 19, weight: .bold)
|
||||
amountLabel.textColor = AppColor.textPrimary
|
||||
amountLabel.textAlignment = .right
|
||||
statusLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.layer.cornerRadius = 11
|
||||
statusLabel.clipsToBounds = true
|
||||
|
||||
let leftStack = UIStackView(arrangedSubviews: [timeLabel, methodLabel, identifierLabel])
|
||||
leftStack.axis = .vertical
|
||||
leftStack.spacing = 4
|
||||
let rightStack = UIStackView(arrangedSubviews: [amountLabel, statusLabel])
|
||||
rightStack.axis = .vertical
|
||||
rightStack.alignment = .trailing
|
||||
rightStack.spacing = 8
|
||||
cardView.addSubview(leftStack)
|
||||
cardView.addSubview(rightStack)
|
||||
contentView.addSubview(cardView)
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(5)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
}
|
||||
leftStack.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.trailing.lessThanOrEqualTo(rightStack.snp.leading).offset(-AppSpacing.sm)
|
||||
}
|
||||
rightStack.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.height.equalTo(22)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日清页在指定营业日无记录时展示的空状态单元格。
|
||||
final class OfflineCollectionEmptyCell: UITableViewCell {
|
||||
static let reuseIdentifier = "OfflineCollectionEmptyCell"
|
||||
var onRegister: (() -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let registerButton = UIButton(type: .system)
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新空状态,仅今日允许前往登记。
|
||||
func apply(isToday: Bool) {
|
||||
registerButton.isHidden = !isToday
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
onRegister = nil
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
titleLabel.text = "当日暂无线下收款记录"
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
detailLabel.text = "完成线下收款后,请及时登记"
|
||||
detailLabel.font = .systemFont(ofSize: 13)
|
||||
detailLabel.textColor = AppColor.textSecondary
|
||||
detailLabel.textAlignment = .center
|
||||
registerButton.setTitle("去登记", for: .normal)
|
||||
registerButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||
registerButton.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, registerButton])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppSpacing.sm
|
||||
stack.alignment = .center
|
||||
contentView.addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().inset(48)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
make.bottom.equalToSuperview().inset(32)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func registerTapped() {
|
||||
onRegister?()
|
||||
}
|
||||
}
|
||||
|
||||
private extension Collection {
|
||||
/// 安全按下标读取元素,超出范围时返回 nil。
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//
|
||||
// OfflineCollectionRegistrationViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 线下收款登记页,仅收集金额和线下收款方式。
|
||||
final class OfflineCollectionRegistrationViewController: BaseViewController {
|
||||
private let viewModel: OfflineCollectionRegistrationViewModel
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let amountCard = UIView()
|
||||
private let amountField = UITextField()
|
||||
private let methodCard = UIView()
|
||||
private let methodStack = UIStackView()
|
||||
private var methodButtons: [OfflineCollectionPaymentMethod: OfflinePaymentMethodButton] = [:]
|
||||
private let explanationCard = UIView()
|
||||
private let contextLabel = UILabel()
|
||||
private let bottomContainer = UIView()
|
||||
private let submitButton = UIButton(type: .system)
|
||||
private var hasFocusedAmountField = false
|
||||
|
||||
/// 使用指定的收款上下文与 Mock 服务创建登记页。
|
||||
init(
|
||||
context: OfflineCollectionContext = .current(),
|
||||
service: OfflineCollectionMockService = .shared
|
||||
) {
|
||||
viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "线下收款登记"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
scrollView.keyboardDismissMode = .interactive
|
||||
scrollView.alwaysBounceVertical = true
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = AppSpacing.md
|
||||
|
||||
configureCard(amountCard)
|
||||
let amountTitleLabel = makeTitleLabel("收款金额")
|
||||
let currencyLabel = UILabel()
|
||||
currencyLabel.text = "¥"
|
||||
currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold)
|
||||
currencyLabel.textColor = AppColor.textPrimary
|
||||
|
||||
amountField.placeholder = "0.00"
|
||||
amountField.font = .systemFont(ofSize: 38, weight: .bold)
|
||||
amountField.textColor = AppColor.textPrimary
|
||||
amountField.keyboardType = .decimalPad
|
||||
amountField.clearButtonMode = .whileEditing
|
||||
amountField.delegate = self
|
||||
amountField.accessibilityLabel = "收款金额"
|
||||
|
||||
let amountHelperLabel = UILabel()
|
||||
amountHelperLabel.text = "单笔金额 0.01~99,999.99 元,最多两位小数"
|
||||
amountHelperLabel.font = .systemFont(ofSize: 12)
|
||||
amountHelperLabel.textColor = AppColor.textTertiary
|
||||
|
||||
let amountRow = UIStackView(arrangedSubviews: [currencyLabel, amountField])
|
||||
amountRow.axis = .horizontal
|
||||
amountRow.alignment = .center
|
||||
amountRow.spacing = AppSpacing.sm
|
||||
let amountStack = UIStackView(arrangedSubviews: [amountTitleLabel, amountRow, amountHelperLabel])
|
||||
amountStack.axis = .vertical
|
||||
amountStack.spacing = AppSpacing.sm
|
||||
amountCard.addSubview(amountStack)
|
||||
amountStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.lg)
|
||||
}
|
||||
amountField.snp.makeConstraints { make in
|
||||
make.height.equalTo(58)
|
||||
}
|
||||
|
||||
configureCard(methodCard)
|
||||
let methodTitleLabel = makeTitleLabel("收款方式")
|
||||
methodStack.axis = .horizontal
|
||||
methodStack.distribution = .fillEqually
|
||||
methodStack.spacing = AppSpacing.sm
|
||||
for method in OfflineCollectionPaymentMethod.allCases {
|
||||
let button = OfflinePaymentMethodButton(method: method)
|
||||
button.addTarget(self, action: #selector(methodTapped(_:)), for: .touchUpInside)
|
||||
methodButtons[method] = button
|
||||
methodStack.addArrangedSubview(button)
|
||||
}
|
||||
let methodContentStack = UIStackView(arrangedSubviews: [methodTitleLabel, methodStack])
|
||||
methodContentStack.axis = .vertical
|
||||
methodContentStack.spacing = AppSpacing.md
|
||||
methodCard.addSubview(methodContentStack)
|
||||
methodContentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.lg)
|
||||
}
|
||||
methodStack.snp.makeConstraints { make in
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
|
||||
configureCard(explanationCard)
|
||||
let explanationIcon = UIImageView(image: UIImage(systemName: "info.circle.fill"))
|
||||
explanationIcon.tintColor = AppColor.primary
|
||||
explanationIcon.contentMode = .scaleAspectFit
|
||||
let explanationLabel = UILabel()
|
||||
explanationLabel.text = "登记后将计入今日待补缴,不生成订单。"
|
||||
explanationLabel.font = .systemFont(ofSize: 14)
|
||||
explanationLabel.textColor = AppColor.textSecondary
|
||||
explanationLabel.numberOfLines = 0
|
||||
explanationCard.addSubview(explanationIcon)
|
||||
explanationCard.addSubview(explanationLabel)
|
||||
explanationIcon.snp.makeConstraints { make in
|
||||
make.leading.top.equalToSuperview().inset(AppSpacing.md)
|
||||
make.width.height.equalTo(20)
|
||||
}
|
||||
explanationLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(explanationIcon.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.top.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
|
||||
contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)"
|
||||
contextLabel.font = .systemFont(ofSize: 12)
|
||||
contextLabel.textColor = AppColor.textTertiary
|
||||
contextLabel.numberOfLines = 0
|
||||
contextLabel.textAlignment = .center
|
||||
|
||||
bottomContainer.backgroundColor = .white
|
||||
bottomContainer.layer.shadowColor = UIColor.black.cgColor
|
||||
bottomContainer.layer.shadowOpacity = 0.06
|
||||
bottomContainer.layer.shadowRadius = 8
|
||||
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2)
|
||||
|
||||
var submitConfiguration = UIButton.Configuration.filled()
|
||||
submitConfiguration.title = "确认登记"
|
||||
submitConfiguration.baseBackgroundColor = AppColor.primary
|
||||
submitConfiguration.baseForegroundColor = .white
|
||||
submitConfiguration.cornerStyle = .medium
|
||||
submitConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
return outgoing
|
||||
}
|
||||
submitButton.configuration = submitConfiguration
|
||||
submitButton.accessibilityIdentifier = "offlineCollection.submit"
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
[amountCard, methodCard, explanationCard, contextLabel].forEach(contentStack.addArrangedSubview)
|
||||
view.addSubview(bottomContainer)
|
||||
bottomContainer.addSubview(submitButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
bottomContainer.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||||
}
|
||||
submitButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().inset(AppSpacing.md)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
make.height.equalTo(50)
|
||||
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(bottomContainer.snp.top)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onRegistrationSuccess = { [weak self] receipt in
|
||||
Task { @MainActor in self?.showRegistrationSuccess(receipt) }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard !hasFocusedAmountField else { return }
|
||||
hasFocusedAmountField = true
|
||||
amountField.becomeFirstResponder()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
if amountField.text != viewModel.amountText {
|
||||
amountField.text = viewModel.amountText
|
||||
}
|
||||
methodButtons.forEach { method, button in
|
||||
button.setSelected(method == viewModel.paymentMethod)
|
||||
}
|
||||
submitButton.isEnabled = viewModel.canSubmit
|
||||
submitButton.configuration?.showsActivityIndicator = viewModel.isSubmitting
|
||||
submitButton.configuration?.title = viewModel.isSubmitting ? "登记中" : "确认登记"
|
||||
submitButton.alpha = submitButton.isEnabled || viewModel.isSubmitting ? 1 : 0.45
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showRegistrationSuccess(_ receipt: OfflineCollectionRegistrationReceipt) {
|
||||
amountField.resignFirstResponder()
|
||||
let message = """
|
||||
本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen))
|
||||
今日累计线下收款 \(OfflineCollectionMoney.display(receipt.summary.totalAmountFen))
|
||||
今日待补缴 \(OfflineCollectionMoney.display(receipt.summary.pendingAmountFen))
|
||||
"""
|
||||
let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in
|
||||
self?.viewModel.startAnotherRegistration()
|
||||
self?.amountField.becomeFirstResponder()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let controller = OfflineCollectionDailyViewController(
|
||||
businessDate: receipt.summary.businessDate,
|
||||
context: self.viewModel.context,
|
||||
service: self.viewModel.service
|
||||
)
|
||||
self.navigationController?.pushViewController(controller, animated: true)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func makeTitleLabel(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
label.textColor = AppColor.textPrimary
|
||||
return label
|
||||
}
|
||||
|
||||
private func configureCard(_ card: UIView) {
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = AppRadius.lg
|
||||
}
|
||||
|
||||
@objc private func amountChanged() {
|
||||
viewModel.updateAmount(amountField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func methodTapped(_ sender: OfflinePaymentMethodButton) {
|
||||
viewModel.selectPaymentMethod(sender.method)
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
Task { await viewModel.submit() }
|
||||
}
|
||||
}
|
||||
|
||||
extension OfflineCollectionRegistrationViewController: UITextFieldDelegate {
|
||||
func textField(
|
||||
_ textField: UITextField,
|
||||
shouldChangeCharactersIn range: NSRange,
|
||||
replacementString string: String
|
||||
) -> Bool {
|
||||
guard let current = textField.text,
|
||||
let textRange = Range(range, in: current) else { return false }
|
||||
let next = current.replacingCharacters(in: textRange, with: string)
|
||||
return OfflineCollectionMoney.acceptsEditingText(next)
|
||||
}
|
||||
}
|
||||
|
||||
/// 登记页的收款方式单选按钮。
|
||||
final class OfflinePaymentMethodButton: UIControl {
|
||||
let method: OfflineCollectionPaymentMethod
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
/// 使用指定收款方式创建单选按钮。
|
||||
init(method: OfflineCollectionPaymentMethod) {
|
||||
self.method = method
|
||||
super.init(frame: .zero)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新选中态视觉。
|
||||
func setSelected(_ selected: Bool) {
|
||||
isSelected = selected
|
||||
backgroundColor = selected ? UIColor(hex: 0xEEF5FF) : AppColor.inputBackground
|
||||
layer.borderColor = (selected ? AppColor.primary : UIColor.clear).cgColor
|
||||
layer.borderWidth = selected ? 1.5 : 0
|
||||
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
layer.cornerRadius = AppRadius.md
|
||||
clipsToBounds = true
|
||||
accessibilityLabel = method.displayName
|
||||
|
||||
iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal)
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
titleLabel.text = method.displayName
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .center
|
||||
stack.spacing = 5
|
||||
stack.isUserInteractionEnabled = false
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(22)
|
||||
}
|
||||
setSelected(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//
|
||||
// OfflineCollectionHomeView.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 收款首页中的线下收款入口、今日汇总和历史逾期提醒区域。
|
||||
final class OfflineCollectionHomeView: UIView {
|
||||
var onRegister: (() -> Void)?
|
||||
var onOpenToday: (() -> Void)?
|
||||
var onOpenOverdue: (() -> Void)?
|
||||
|
||||
private let contentStack = UIStackView()
|
||||
private let sectionTitleLabel = UILabel()
|
||||
|
||||
private let overdueControl = UIControl()
|
||||
private let overdueTitleLabel = UILabel()
|
||||
private let overdueDetailLabel = UILabel()
|
||||
private let overdueActionLabel = UILabel()
|
||||
|
||||
private let registerControl = UIControl()
|
||||
private let registerIconView = UIImageView()
|
||||
private let registerTitleLabel = UILabel()
|
||||
private let registerSubtitleLabel = UILabel()
|
||||
private let registerActionLabel = UILabel()
|
||||
|
||||
private let summaryControl = UIControl()
|
||||
private let summaryTitleLabel = UILabel()
|
||||
private let summaryAmountLabel = UILabel()
|
||||
private let summaryCountLabel = UILabel()
|
||||
private let summaryRegisteredLabel = UILabel()
|
||||
private let summaryStatusLabel = UILabel()
|
||||
private let summaryActionLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
bindActions()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 根据今日与历史逾期汇总刷新页面内容。
|
||||
func apply(
|
||||
today: OfflineDailySummary,
|
||||
overdueDayCount: Int,
|
||||
overdueRecordCount: Int,
|
||||
overdueAmountFen: Int
|
||||
) {
|
||||
let hasOverdue = overdueDayCount > 0 && overdueAmountFen > 0
|
||||
overdueControl.isHidden = !hasOverdue
|
||||
overdueDetailLabel.text = "\(overdueDayCount) 个营业日,共 \(overdueRecordCount) 笔,待补缴 \(OfflineCollectionMoney.display(overdueAmountFen))"
|
||||
|
||||
summaryAmountLabel.text = OfflineCollectionMoney.display(today.pendingAmountFen)
|
||||
summaryCountLabel.text = "\(today.pendingCount) 笔线下收款"
|
||||
summaryRegisteredLabel.text = "今日已登记 \(OfflineCollectionMoney.display(today.totalAmountFen))"
|
||||
let isSettled = today.totalCount > 0 && today.pendingAmountFen == 0
|
||||
summaryStatusLabel.text = isSettled ? "今日已结清" : ""
|
||||
summaryStatusLabel.isHidden = !isSettled
|
||||
summaryActionLabel.text = today.pendingAmountFen > 0 ? "去补缴" : "查看明细"
|
||||
summaryActionLabel.backgroundColor = today.pendingAmountFen > 0
|
||||
? AppColor.primary
|
||||
: UIColor(hex: 0xEEF2F7)
|
||||
summaryActionLabel.textColor = today.pendingAmountFen > 0
|
||||
? .white
|
||||
: AppColor.textSecondary
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .clear
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = AppSpacing.md
|
||||
|
||||
sectionTitleLabel.text = "线下收款"
|
||||
sectionTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
sectionTitleLabel.textColor = AppColor.textPrimary
|
||||
|
||||
configureCard(overdueControl, backgroundColor: UIColor(hex: 0xFFF1F0))
|
||||
overdueControl.layer.borderColor = UIColor(hex: 0xFFCCC7).cgColor
|
||||
overdueControl.layer.borderWidth = 1
|
||||
overdueTitleLabel.text = "存在逾期未补缴"
|
||||
overdueTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
overdueTitleLabel.textColor = AppColor.danger
|
||||
overdueDetailLabel.font = .systemFont(ofSize: 13)
|
||||
overdueDetailLabel.textColor = UIColor(hex: 0x8C2F27)
|
||||
overdueDetailLabel.numberOfLines = 0
|
||||
configureActionLabel(overdueActionLabel, title: "立即处理", background: AppColor.danger, foreground: .white)
|
||||
|
||||
configureCard(registerControl, backgroundColor: .white)
|
||||
registerIconView.image = UIImage(systemName: "plus.circle.fill")
|
||||
registerIconView.tintColor = AppColor.primary
|
||||
registerIconView.contentMode = .scaleAspectFit
|
||||
registerTitleLabel.text = "线下收款登记"
|
||||
registerTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
registerTitleLabel.textColor = AppColor.textPrimary
|
||||
registerSubtitleLabel.text = "线下收款后,请及时登记并在当日完成补缴"
|
||||
registerSubtitleLabel.font = .systemFont(ofSize: 13)
|
||||
registerSubtitleLabel.textColor = AppColor.textSecondary
|
||||
registerSubtitleLabel.numberOfLines = 0
|
||||
configureActionLabel(registerActionLabel, title: "去登记", background: UIColor(hex: 0xEEF5FF), foreground: AppColor.primary)
|
||||
|
||||
configureCard(summaryControl, backgroundColor: .white)
|
||||
summaryTitleLabel.text = "今日待补缴"
|
||||
summaryTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
summaryTitleLabel.textColor = AppColor.textPrimary
|
||||
summaryAmountLabel.font = .systemFont(ofSize: 30, weight: .bold)
|
||||
summaryAmountLabel.textColor = AppColor.textPrimary
|
||||
summaryCountLabel.font = .systemFont(ofSize: 13)
|
||||
summaryCountLabel.textColor = UIColor(hex: 0xD97706)
|
||||
summaryRegisteredLabel.font = .systemFont(ofSize: 13)
|
||||
summaryRegisteredLabel.textColor = AppColor.textSecondary
|
||||
summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
summaryStatusLabel.textColor = AppColor.success
|
||||
configureActionLabel(summaryActionLabel, title: "查看明细", background: UIColor(hex: 0xEEF2F7), foreground: AppColor.textSecondary)
|
||||
|
||||
addSubview(contentStack)
|
||||
[sectionTitleLabel, overdueControl, registerControl, summaryControl].forEach(contentStack.addArrangedSubview)
|
||||
|
||||
let overdueTextStack = UIStackView(arrangedSubviews: [overdueTitleLabel, overdueDetailLabel])
|
||||
overdueTextStack.axis = .vertical
|
||||
overdueTextStack.spacing = 5
|
||||
overdueControl.addSubview(overdueTextStack)
|
||||
overdueControl.addSubview(overdueActionLabel)
|
||||
|
||||
let registerTextStack = UIStackView(arrangedSubviews: [registerTitleLabel, registerSubtitleLabel])
|
||||
registerTextStack.axis = .vertical
|
||||
registerTextStack.spacing = 5
|
||||
registerControl.addSubview(registerIconView)
|
||||
registerControl.addSubview(registerTextStack)
|
||||
registerControl.addSubview(registerActionLabel)
|
||||
|
||||
let summaryTextStack = UIStackView(arrangedSubviews: [
|
||||
summaryTitleLabel,
|
||||
summaryAmountLabel,
|
||||
summaryCountLabel,
|
||||
summaryRegisteredLabel,
|
||||
summaryStatusLabel,
|
||||
])
|
||||
summaryTextStack.axis = .vertical
|
||||
summaryTextStack.spacing = 6
|
||||
summaryControl.addSubview(summaryTextStack)
|
||||
summaryControl.addSubview(summaryActionLabel)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
sectionTitleLabel.snp.makeConstraints { make in
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
|
||||
overdueControl.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(84)
|
||||
}
|
||||
overdueControl.subviews.first?.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.trailing.lessThanOrEqualTo(overdueActionLabel.snp.leading).offset(-AppSpacing.sm)
|
||||
}
|
||||
overdueActionLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.centerY.equalToSuperview()
|
||||
make.height.equalTo(32)
|
||||
}
|
||||
|
||||
registerControl.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(96)
|
||||
}
|
||||
registerIconView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().inset(AppSpacing.md)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(34)
|
||||
}
|
||||
registerTitleLabel.superview?.snp.makeConstraints { make in
|
||||
make.leading.equalTo(registerIconView.snp.trailing).offset(AppSpacing.sm)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(registerActionLabel.snp.leading).offset(-AppSpacing.sm)
|
||||
}
|
||||
registerActionLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.centerY.equalToSuperview()
|
||||
make.height.equalTo(32)
|
||||
}
|
||||
|
||||
summaryControl.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(180)
|
||||
}
|
||||
summaryTitleLabel.superview?.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.trailing.lessThanOrEqualTo(summaryActionLabel.snp.leading).offset(-AppSpacing.sm)
|
||||
}
|
||||
summaryActionLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
}
|
||||
|
||||
private func bindActions() {
|
||||
overdueControl.addTarget(self, action: #selector(overdueTapped), for: .touchUpInside)
|
||||
registerControl.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
|
||||
summaryControl.addTarget(self, action: #selector(summaryTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func configureCard(_ control: UIControl, backgroundColor: UIColor) {
|
||||
control.backgroundColor = backgroundColor
|
||||
control.layer.cornerRadius = AppRadius.lg
|
||||
control.clipsToBounds = true
|
||||
}
|
||||
|
||||
private func configureActionLabel(
|
||||
_ label: UILabel,
|
||||
title: String,
|
||||
background: UIColor,
|
||||
foreground: UIColor
|
||||
) {
|
||||
label.text = " \(title) "
|
||||
label.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
label.textAlignment = .center
|
||||
label.textColor = foreground
|
||||
label.backgroundColor = background
|
||||
label.layer.cornerRadius = 16
|
||||
label.clipsToBounds = true
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
}
|
||||
|
||||
@objc private func overdueTapped() {
|
||||
onOpenOverdue?()
|
||||
}
|
||||
|
||||
@objc private func registerTapped() {
|
||||
onRegister?()
|
||||
}
|
||||
|
||||
@objc private func summaryTapped() {
|
||||
onOpenToday?()
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
|
||||
private let viewModel = PaymentCollectionDetailsViewModel()
|
||||
private let paymentAPI = NetworkServices.shared.paymentAPI
|
||||
private let offlineCollectionViewModel = OfflineCollectionHomeViewModel()
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentContainerView = UIView()
|
||||
@@ -28,6 +29,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
private let qrContainerView = UIView()
|
||||
private let qrImageView = UIImageView()
|
||||
private let qrPlaceholderView = UIView()
|
||||
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let qrErrorLabel = UILabel()
|
||||
private let refreshButton = UIButton(type: .system)
|
||||
private let setAmountButton = UIButton(type: .system)
|
||||
@@ -47,6 +49,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
private let voiceCardView = UIView()
|
||||
private let voiceTitleLabel = UILabel()
|
||||
private let voiceSwitch = UISwitch()
|
||||
private let offlineCollectionView = OfflineCollectionHomeView()
|
||||
|
||||
private var amountDialog: PaymentSetAmountDialogView?
|
||||
private var appliedBrandConfig: PayPageConfig?
|
||||
@@ -99,9 +102,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
override func setupUI() {
|
||||
let usesBranding = viewModel.usesNalatiBranding
|
||||
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
|
||||
scrollView.showsVerticalScrollIndicator = !usesBranding
|
||||
scrollView.isScrollEnabled = !usesBranding
|
||||
scrollView.alwaysBounceVertical = false
|
||||
scrollView.showsVerticalScrollIndicator = true
|
||||
scrollView.isScrollEnabled = true
|
||||
scrollView.alwaysBounceVertical = true
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
|
||||
@@ -127,6 +130,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
|
||||
qrPlaceholderView.isHidden = true
|
||||
|
||||
qrLoadingIndicator.color = AppColor.primary
|
||||
qrLoadingIndicator.hidesWhenStopped = true
|
||||
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
|
||||
|
||||
qrErrorLabel.text = "未选景区或网络问题"
|
||||
qrErrorLabel.font = .systemFont(ofSize: 14)
|
||||
qrErrorLabel.textColor = AppColor.danger
|
||||
@@ -150,6 +157,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
trailing: 24
|
||||
)
|
||||
}
|
||||
actionRow.alpha = 0
|
||||
actionRow.isUserInteractionEnabled = false
|
||||
actionRow.accessibilityElementsHidden = true
|
||||
|
||||
configureLinkButton(setAmountButton, title: "设置金额")
|
||||
configureLinkButton(saveQRButton, title: "保存二维码")
|
||||
@@ -193,6 +203,8 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
contentStack.addArrangedSubview(recordRow)
|
||||
contentStack.addArrangedSubview(voiceCardView)
|
||||
}
|
||||
// 线下收款与原有真实收款功能并列,不替换二维码或收款记录入口。
|
||||
contentStack.addArrangedSubview(offlineCollectionView)
|
||||
|
||||
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
|
||||
let qrContentStack = UIStackView(arrangedSubviews: [
|
||||
@@ -206,6 +218,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
|
||||
qrCardView.addSubview(qrContentStack)
|
||||
|
||||
qrPlaceholderView.addSubview(qrLoadingIndicator)
|
||||
qrPlaceholderView.addSubview(qrErrorLabel)
|
||||
qrPlaceholderView.addSubview(refreshButton)
|
||||
|
||||
@@ -275,6 +288,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
qrPlaceholderView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
|
||||
}
|
||||
qrLoadingIndicator.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
qrErrorLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(56)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
|
||||
@@ -318,15 +334,14 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
make.edges.equalToSuperview()
|
||||
make.width.equalTo(scrollView.snp.width)
|
||||
if viewModel.usesNalatiBranding {
|
||||
make.height.equalTo(scrollView.snp.height)
|
||||
make.height.greaterThanOrEqualTo(scrollView.snp.height)
|
||||
}
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
||||
if viewModel.usesNalatiBranding {
|
||||
make.centerX.centerY.equalToSuperview()
|
||||
make.top.greaterThanOrEqualToSuperview()
|
||||
make.bottom.lessThanOrEqualToSuperview()
|
||||
make.centerX.equalToSuperview()
|
||||
make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
} else {
|
||||
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||
}
|
||||
@@ -340,6 +355,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
offlineCollectionViewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyOfflineCollection() }
|
||||
}
|
||||
offlineCollectionView.onRegister = { [weak self] in
|
||||
self?.openOfflineCollectionRegistration()
|
||||
}
|
||||
offlineCollectionView.onOpenToday = { [weak self] in
|
||||
self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.todayBusinessDate)
|
||||
}
|
||||
offlineCollectionView.onOpenOverdue = { [weak self] in
|
||||
self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.earliestOverdueBusinessDate)
|
||||
}
|
||||
|
||||
setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside)
|
||||
saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside)
|
||||
@@ -357,6 +384,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
applyBrandNavigationAppearanceIfNeeded()
|
||||
offlineCollectionViewModel.load()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
@@ -370,9 +398,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
|
||||
private func loadData() async {
|
||||
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
|
||||
showLoading()
|
||||
await viewModel.loadPayCode(api: paymentAPI)
|
||||
hideLoading()
|
||||
await pageConfig
|
||||
}
|
||||
|
||||
@@ -387,17 +413,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
|
||||
applyBrandConfigIfNeeded()
|
||||
|
||||
if let image = viewModel.qrImage {
|
||||
if viewModel.isPayCodeLoading {
|
||||
qrImageView.isHidden = true
|
||||
qrContainerView.isHidden = true
|
||||
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
|
||||
? .white
|
||||
: AppColor.inputBackground
|
||||
qrPlaceholderView.isHidden = false
|
||||
qrErrorLabel.isHidden = true
|
||||
refreshButton.isHidden = true
|
||||
qrLoadingIndicator.startAnimating()
|
||||
setActionRowAvailable(false)
|
||||
} else if let image = viewModel.qrImage {
|
||||
qrImageView.image = image
|
||||
qrImageView.isHidden = false
|
||||
qrContainerView.isHidden = !viewModel.usesNalatiBranding
|
||||
qrPlaceholderView.isHidden = true
|
||||
actionRow.isHidden = false
|
||||
qrLoadingIndicator.stopAnimating()
|
||||
setActionRowAvailable(true)
|
||||
} else {
|
||||
qrImageView.isHidden = true
|
||||
qrContainerView.isHidden = true
|
||||
qrPlaceholderView.backgroundColor = AppColor.inputBackground
|
||||
qrPlaceholderView.isHidden = false
|
||||
actionRow.isHidden = true
|
||||
qrErrorLabel.isHidden = false
|
||||
refreshButton.isHidden = false
|
||||
qrLoadingIndicator.stopAnimating()
|
||||
setActionRowAvailable(false)
|
||||
}
|
||||
|
||||
if viewModel.showAmountDialog {
|
||||
@@ -406,6 +448,23 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
amountDialog?.dismiss()
|
||||
amountDialog = nil
|
||||
}
|
||||
applyOfflineCollection()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyOfflineCollection() {
|
||||
offlineCollectionView.apply(
|
||||
today: offlineCollectionViewModel.todaySummary,
|
||||
overdueDayCount: offlineCollectionViewModel.overdueSummaries.count,
|
||||
overdueRecordCount: offlineCollectionViewModel.overdueRecordCount,
|
||||
overdueAmountFen: offlineCollectionViewModel.overdueAmountFen
|
||||
)
|
||||
}
|
||||
|
||||
private func setActionRowAvailable(_ isAvailable: Bool) {
|
||||
actionRow.alpha = isAvailable ? 1 : 0
|
||||
actionRow.isUserInteractionEnabled = isAvailable
|
||||
actionRow.accessibilityElementsHidden = !isAvailable
|
||||
}
|
||||
|
||||
private func presentAmountDialogIfNeeded() {
|
||||
@@ -625,9 +684,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
|
||||
@objc private func refreshTapped() {
|
||||
Task {
|
||||
showLoading()
|
||||
await viewModel.refresh(api: paymentAPI)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +695,24 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
||||
@objc private func voiceSwitchChanged() {
|
||||
viewModel.toggleReceiveVoice()
|
||||
}
|
||||
|
||||
private func openOfflineCollectionRegistration() {
|
||||
let controller = OfflineCollectionRegistrationViewController(
|
||||
context: offlineCollectionViewModel.context,
|
||||
service: offlineCollectionViewModel.service
|
||||
)
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
|
||||
private func openOfflineCollectionDaily(date: String?) {
|
||||
guard let date else { return }
|
||||
let controller = OfflineCollectionDailyViewController(
|
||||
businessDate: date,
|
||||
context: offlineCollectionViewModel.context,
|
||||
service: offlineCollectionViewModel.service
|
||||
)
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 收款详情信息行。
|
||||
|
||||
@@ -38,6 +38,11 @@ final class ProfileViewController: BaseViewController {
|
||||
title = "我的"
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF7FAFF)
|
||||
|
||||
@@ -104,14 +109,12 @@ final class ProfileViewController: BaseViewController {
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
let showLoading = !hasLoadedProfileOnce
|
||||
let showLoading = !hasLoadedProfileOnce && !viewModel.hasCachedBasicInfo
|
||||
Task {
|
||||
await reloadProfile(showGlobalLoading: showLoading)
|
||||
if showLoading {
|
||||
hasLoadedProfileOnce = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let headerContainer = UIView()
|
||||
private let infoCardWrapper = UIView()
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Kingfisher
|
||||
import Photos
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
import MapKit
|
||||
#endif
|
||||
|
||||
/// 打卡点详情页,对齐 Android `PunchPointDetailScreen`。
|
||||
final class PunchPointDetailViewController: BaseViewController {
|
||||
private let viewModel: PunchPointDetailViewModel
|
||||
@@ -447,7 +452,192 @@ final class PunchPointDetailViewController: BaseViewController {
|
||||
}
|
||||
|
||||
/// 高德地图容器,供打卡点详情与表单复用。
|
||||
/// 打卡点地图视图,封装高德地图 marker、缩放、定位与点击选点行为。
|
||||
/// 打卡点地图视图,模拟器使用 MapKit 提供 marker、缩放、定位与点击选点行为。
|
||||
#if targetEnvironment(simulator)
|
||||
final class PunchPointMapView: UIView, MKMapViewDelegate {
|
||||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||||
|
||||
private let mapView: MKMapView
|
||||
private var punchPointAnnotation: MKPointAnnotation?
|
||||
private var selectedAnnotation: MKPointAnnotation?
|
||||
private var punchPointImageURL: URL?
|
||||
private var fitWithUserLocation = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
mapView = MKMapView(frame: .zero)
|
||||
super.init(frame: frame)
|
||||
mapView.delegate = self
|
||||
mapView.showsUserLocation = true
|
||||
mapView.showsCompass = false
|
||||
mapView.showsScale = false
|
||||
mapView.pointOfInterestFilter = .includingAll
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
|
||||
tapGesture.cancelsTouchesInView = false
|
||||
mapView.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新原打卡点标记,可使用首张图片作为圆角 Marker。
|
||||
func updatePunchPointMarker(
|
||||
coordinate: CLLocationCoordinate2D?,
|
||||
imageURL: String? = nil,
|
||||
fitWithUserLocation: Bool = false
|
||||
) {
|
||||
if let punchPointAnnotation {
|
||||
mapView.removeAnnotation(punchPointAnnotation)
|
||||
self.punchPointAnnotation = nil
|
||||
}
|
||||
punchPointImageURL = imageURL.flatMap(URL.init(string:))
|
||||
self.fitWithUserLocation = fitWithUserLocation
|
||||
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||||
let annotation = MKPointAnnotation()
|
||||
annotation.coordinate = coordinate
|
||||
annotation.title = "punch_point_original"
|
||||
punchPointAnnotation = annotation
|
||||
mapView.addAnnotation(annotation)
|
||||
if fitWithUserLocation {
|
||||
fitPunchPointAndUserLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容仅需更新普通 Marker 的调用。
|
||||
func updateMarker(coordinate: CLLocationCoordinate2D?) {
|
||||
updatePunchPointMarker(coordinate: coordinate)
|
||||
}
|
||||
|
||||
/// 更新地图点击产生的新位置 Marker。
|
||||
func updateSelectedMarker(coordinate: CLLocationCoordinate2D?) {
|
||||
if let selectedAnnotation {
|
||||
mapView.removeAnnotation(selectedAnnotation)
|
||||
self.selectedAnnotation = nil
|
||||
}
|
||||
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
|
||||
let annotation = MKPointAnnotation()
|
||||
annotation.coordinate = coordinate
|
||||
annotation.title = "punch_point_selected"
|
||||
selectedAnnotation = annotation
|
||||
mapView.addAnnotation(annotation)
|
||||
}
|
||||
|
||||
/// 设置地图中心点。
|
||||
func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double = 15, animated: Bool = true) {
|
||||
let delta = min(max(0.012 * pow(2, 15 - zoomLevel), 0.0005), 120)
|
||||
let region = MKCoordinateRegion(
|
||||
center: coordinate,
|
||||
span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta)
|
||||
)
|
||||
mapView.setRegion(region, animated: animated)
|
||||
}
|
||||
|
||||
/// 放大地图。
|
||||
func zoomIn() {
|
||||
updateZoom(scale: 0.5)
|
||||
}
|
||||
|
||||
/// 缩小地图。
|
||||
func zoomOut() {
|
||||
updateZoom(scale: 2)
|
||||
}
|
||||
|
||||
/// 居中到用户当前位置。
|
||||
func centerOnUserLocation() {
|
||||
guard let coordinate = mapView.userLocation.location?.coordinate,
|
||||
CLLocationCoordinate2DIsValid(coordinate),
|
||||
coordinate.latitude != 0 || coordinate.longitude != 0 else { return }
|
||||
setCenter(coordinate)
|
||||
}
|
||||
|
||||
/// 同时展示原打卡点、用户位置和新选位置。
|
||||
func fitAllMarkers(edgePadding: UIEdgeInsets = UIEdgeInsets(top: 80, left: 56, bottom: 80, right: 56)) {
|
||||
var annotations: [any MKAnnotation] = []
|
||||
if let punchPointAnnotation { annotations.append(punchPointAnnotation) }
|
||||
if let selectedAnnotation { annotations.append(selectedAnnotation) }
|
||||
if mapView.userLocation.location != nil { annotations.append(mapView.userLocation) }
|
||||
guard !annotations.isEmpty else { return }
|
||||
if annotations.count == 1, let annotation = annotations.first {
|
||||
setCenter(annotation.coordinate, zoomLevel: 16)
|
||||
} else {
|
||||
var mapRect = MKMapRect.null
|
||||
for annotation in annotations {
|
||||
let point = MKMapPoint(annotation.coordinate)
|
||||
mapRect = mapRect.union(MKMapRect(
|
||||
origin: point,
|
||||
size: MKMapSize(width: 1, height: 1)
|
||||
))
|
||||
}
|
||||
mapView.setVisibleMapRect(mapRect, edgePadding: edgePadding, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? {
|
||||
if annotation is MKUserLocation { return nil }
|
||||
let isOriginal = (annotation as AnyObject) === punchPointAnnotation
|
||||
let reuseIdentifier = isOriginal ? "PunchPointImageMarker" : "PunchPointSelectedMarker"
|
||||
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
|
||||
?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
||||
annotationView.annotation = annotation
|
||||
annotationView.canShowCallout = false
|
||||
annotationView.centerOffset = CGPoint(x: 0, y: -12)
|
||||
|
||||
if isOriginal, let punchPointImageURL {
|
||||
annotationView.image = UIImage(named: "punch_point_marker")
|
||||
KingfisherManager.shared.retrieveImage(with: punchPointImageURL) { result in
|
||||
guard case let .success(value) = result else { return }
|
||||
Task { @MainActor in
|
||||
guard annotationView.annotation === annotation else { return }
|
||||
annotationView.image = Self.roundedMarkerImage(value.image)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
annotationView.image = UIImage(named: "punch_point_marker")
|
||||
}
|
||||
return annotationView
|
||||
}
|
||||
|
||||
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
|
||||
guard userLocation.location != nil else { return }
|
||||
fitPunchPointAndUserLocation()
|
||||
}
|
||||
|
||||
private func fitPunchPointAndUserLocation() {
|
||||
guard fitWithUserLocation else { return }
|
||||
fitAllMarkers(edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: 80, right: 50))
|
||||
}
|
||||
|
||||
private static func roundedMarkerImage(_ source: UIImage) -> UIImage {
|
||||
let size = CGSize(width: 40, height: 40)
|
||||
return UIGraphicsImageRenderer(size: size).image { _ in
|
||||
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 6).addClip()
|
||||
source.draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard gesture.state == .ended else { return }
|
||||
let point = gesture.location(in: mapView)
|
||||
onMapTap?(mapView.convert(point, toCoordinateFrom: mapView))
|
||||
}
|
||||
|
||||
private func updateZoom(scale: Double) {
|
||||
let current = mapView.region
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: min(max(current.span.latitudeDelta * scale, 0.0005), 120),
|
||||
longitudeDelta: min(max(current.span.longitudeDelta * scale, 0.0005), 120)
|
||||
)
|
||||
mapView.setRegion(MKCoordinateRegion(center: current.center, span: span), animated: true)
|
||||
}
|
||||
}
|
||||
#else
|
||||
/// 打卡点地图视图,真机封装高德地图 marker、缩放、定位与点击选点行为。
|
||||
final class PunchPointMapView: UIView, MAMapViewDelegate {
|
||||
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
|
||||
|
||||
@@ -614,6 +804,7 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
|
||||
onMapTap?(poi.coordinate)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 打卡点地图控制按钮。
|
||||
/// 地图悬浮控制按钮。
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
//
|
||||
// AccountDeletionViewControllers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 注销账号资产核验页,中性展示账号资产与注销后果。
|
||||
final class AccountDeletionViewController: BaseViewController {
|
||||
private let viewModel: AccountDeletionViewModel
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentView = UIView()
|
||||
private let contentStack = UIStackView()
|
||||
private let warningView = AccountDeletionWarningView()
|
||||
private let assetCardView = AccountDeletionAssetCardView()
|
||||
private let detailButton = UIButton(type: .system)
|
||||
private let consequenceCardView = AccountDeletionConsequenceCardView()
|
||||
private let bottomPanel = UIView()
|
||||
private let acknowledgementButton = UIButton(type: .system)
|
||||
private let continueButton = AppButton(title: "继续注销", style: .primary)
|
||||
private var lastErrorMessage: String?
|
||||
|
||||
/// 创建注销资产核验页。
|
||||
init(viewModel: AccountDeletionViewModel = AccountDeletionViewModel()) {
|
||||
self.viewModel = viewModel
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "注销账号"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
contentStack.isLayoutMarginsRelativeArrangement = true
|
||||
contentStack.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
|
||||
|
||||
detailButton.setTitle("查看资产明细", for: .normal)
|
||||
detailButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
detailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
|
||||
bottomPanel.backgroundColor = AppColor.pageBackground
|
||||
|
||||
acknowledgementButton.contentHorizontalAlignment = .leading
|
||||
acknowledgementButton.tintColor = AppColor.primary
|
||||
acknowledgementButton.setTitleColor(AppColor.textPrimary, for: .normal)
|
||||
acknowledgementButton.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
acknowledgementButton.titleLabel?.numberOfLines = 0
|
||||
acknowledgementButton.setTitle(" 我已知晓并自愿放弃以上资产和权益", for: .normal)
|
||||
acknowledgementButton.accessibilityLabel = "我已知晓并自愿放弃以上资产和权益"
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentView)
|
||||
contentView.addSubview(contentStack)
|
||||
view.addSubview(bottomPanel)
|
||||
bottomPanel.addSubview(acknowledgementButton)
|
||||
bottomPanel.addSubview(continueButton)
|
||||
|
||||
[warningView, assetCardView, detailButton, consequenceCardView].forEach(contentStack.addArrangedSubview)
|
||||
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
bottomPanel.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
|
||||
acknowledgementButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.greaterThanOrEqualTo(44)
|
||||
}
|
||||
|
||||
continueButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(acknowledgementButton.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(bottomPanel.snp.top)
|
||||
}
|
||||
|
||||
contentView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.width.equalTo(scrollView.snp.width)
|
||||
}
|
||||
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
warningView.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(64)
|
||||
}
|
||||
detailButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
acknowledgementButton.addTarget(self, action: #selector(acknowledgementTapped), for: .touchUpInside)
|
||||
continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside)
|
||||
detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task {
|
||||
showLoading()
|
||||
await viewModel.load()
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
assetCardView.configure(assets: viewModel.assets)
|
||||
consequenceCardView.configure(items: viewModel.consequences)
|
||||
acknowledgementButton.setImage(
|
||||
UIImage(systemName: viewModel.isAcknowledged ? "checkmark.square.fill" : "square"),
|
||||
for: .normal
|
||||
)
|
||||
acknowledgementButton.accessibilityValue = viewModel.isAcknowledged ? "已勾选" : "未勾选"
|
||||
continueButton.isEnabled = viewModel.isContinueButtonEnabled
|
||||
|
||||
if let errorMessage = viewModel.errorMessage,
|
||||
!errorMessage.isEmpty,
|
||||
errorMessage != lastErrorMessage {
|
||||
lastErrorMessage = errorMessage
|
||||
showToast(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func acknowledgementTapped() {
|
||||
viewModel.toggleAcknowledgement()
|
||||
}
|
||||
|
||||
@objc private func continueTapped() {
|
||||
do {
|
||||
let verificationViewModel = try viewModel.makeVerificationViewModel()
|
||||
navigationController?.pushViewController(
|
||||
AccountDeletionVerificationViewController(viewModel: verificationViewModel),
|
||||
animated: true
|
||||
)
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func detailTapped() {
|
||||
let message = viewModel.assets
|
||||
.map { "\($0.title):\($0.valueText)" }
|
||||
.joined(separator: "\n")
|
||||
showAlert(title: "当前账号资产", message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销手机号验证页,使用短信验证码确认是账号本人操作。
|
||||
final class AccountDeletionVerificationViewController: BaseViewController {
|
||||
private let viewModel: AccountDeletionVerificationViewModel
|
||||
|
||||
private let contentStack = UIStackView()
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let descriptionLabel = UILabel()
|
||||
private let codeContainer = UIView()
|
||||
private let codeField = UITextField()
|
||||
private let resendButton = UIButton(type: .system)
|
||||
private let demoHintLabel = UILabel()
|
||||
private let noticeView = AccountDeletionNoticeView()
|
||||
private let submitButton = AppButton(title: "提交注销申请", style: .primary)
|
||||
private var lastErrorMessage: String?
|
||||
|
||||
/// 创建注销手机号验证页。
|
||||
init(viewModel: AccountDeletionVerificationViewModel) {
|
||||
self.viewModel = viewModel
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "验证手机号"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.alignment = .fill
|
||||
contentStack.spacing = 16
|
||||
|
||||
iconView.image = UIImage(systemName: "message.badge.filled.fill")
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
titleLabel.text = "确认是你本人操作"
|
||||
titleLabel.font = .systemFont(ofSize: 22, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
descriptionLabel.text = "验证码将发送至 \(viewModel.context.maskedPhone)"
|
||||
descriptionLabel.font = .systemFont(ofSize: 15)
|
||||
descriptionLabel.textColor = AppColor.textSecondary
|
||||
descriptionLabel.textAlignment = .center
|
||||
|
||||
codeContainer.backgroundColor = .white
|
||||
codeContainer.layer.cornerRadius = 12
|
||||
codeContainer.layer.borderWidth = 1
|
||||
codeContainer.layer.borderColor = AppColor.border.cgColor
|
||||
|
||||
codeField.placeholder = "请输入6位验证码"
|
||||
codeField.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
codeField.textColor = AppColor.textPrimary
|
||||
codeField.keyboardType = .numberPad
|
||||
codeField.textContentType = .oneTimeCode
|
||||
codeField.clearButtonMode = .whileEditing
|
||||
|
||||
resendButton.setTitle("重新发送", for: .normal)
|
||||
resendButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
resendButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
|
||||
demoHintLabel.text = "演示验证码:123456"
|
||||
demoHintLabel.font = .systemFont(ofSize: 13)
|
||||
demoHintLabel.textColor = AppColor.warning
|
||||
demoHintLabel.textAlignment = .center
|
||||
|
||||
noticeView.configure(
|
||||
iconName: "calendar.badge.exclamationmark",
|
||||
text: "提交成功后账号将退出,7天内再次登录可取消注销。"
|
||||
)
|
||||
|
||||
view.addSubview(contentStack)
|
||||
view.addSubview(submitButton)
|
||||
codeContainer.addSubview(codeField)
|
||||
codeContainer.addSubview(resendButton)
|
||||
|
||||
[iconView, titleLabel, descriptionLabel, codeContainer, demoHintLabel, noticeView]
|
||||
.forEach(contentStack.addArrangedSubview)
|
||||
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(32)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.height.equalTo(64)
|
||||
}
|
||||
codeContainer.snp.makeConstraints { make in
|
||||
make.height.equalTo(56)
|
||||
}
|
||||
codeField.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.top.bottom.equalToSuperview()
|
||||
make.trailing.equalTo(resendButton.snp.leading).offset(-8)
|
||||
}
|
||||
resendButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-12)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.equalTo(76)
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
noticeView.snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(64)
|
||||
}
|
||||
submitButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
codeField.addTarget(self, action: #selector(codeChanged), for: .editingChanged)
|
||||
resendButton.addTarget(self, action: #selector(resendTapped), for: .touchUpInside)
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task {
|
||||
await viewModel.sendVerificationCode()
|
||||
if viewModel.hasSentCode {
|
||||
showToast("验证码已发送")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
if codeField.text != viewModel.verificationCode {
|
||||
codeField.text = viewModel.verificationCode
|
||||
}
|
||||
resendButton.isEnabled = !viewModel.isSendingCode && !viewModel.isSubmitting
|
||||
resendButton.setTitle(viewModel.isSendingCode ? "发送中" : "重新发送", for: .normal)
|
||||
submitButton.isEnabled = viewModel.isSubmitEnabled
|
||||
|
||||
if let errorMessage = viewModel.errorMessage,
|
||||
!errorMessage.isEmpty,
|
||||
errorMessage != lastErrorMessage {
|
||||
lastErrorMessage = errorMessage
|
||||
showToast(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func codeChanged() {
|
||||
viewModel.updateVerificationCode(codeField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func resendTapped() {
|
||||
Task {
|
||||
await viewModel.sendVerificationCode()
|
||||
if viewModel.hasSentCode {
|
||||
showToast("验证码已重新发送")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
let alert = UIAlertController(
|
||||
title: "确认提交注销申请?",
|
||||
message: "提交后账号将退出,7天内再次登录可以取消注销。",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "再想想", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "确认提交", style: .destructive) { [weak self] _ in
|
||||
self?.submitDeletion()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func submitDeletion() {
|
||||
Task {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
do {
|
||||
let request = try await viewModel.submit()
|
||||
navigationController?.pushViewController(
|
||||
AccountDeletionSuccessViewController(request: request),
|
||||
animated: true
|
||||
)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销申请提交成功页,明确展示计划删除时间和恢复方式。
|
||||
final class AccountDeletionSuccessViewController: BaseViewController {
|
||||
private let request: AccountDeletionRequest
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let descriptionLabel = UILabel()
|
||||
private let dateCardView = UIView()
|
||||
private let dateTitleLabel = UILabel()
|
||||
private let dateValueLabel = UILabel()
|
||||
private let recoveryLabel = UILabel()
|
||||
private let exitButton = AppButton(title: "退出并返回登录页", style: .primary)
|
||||
|
||||
/// 创建注销申请提交成功页。
|
||||
init(request: AccountDeletionRequest) {
|
||||
self.request = request
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "注销账号"
|
||||
navigationItem.hidesBackButton = true
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
|
||||
iconView.image = UIImage(systemName: "checkmark.circle.fill")
|
||||
iconView.tintColor = AppColor.success
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
titleLabel.text = "注销申请已提交"
|
||||
titleLabel.font = .systemFont(ofSize: 24, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
descriptionLabel.text = "账号已进入7天注销冷静期"
|
||||
descriptionLabel.font = .systemFont(ofSize: 15)
|
||||
descriptionLabel.textColor = AppColor.textSecondary
|
||||
descriptionLabel.textAlignment = .center
|
||||
|
||||
dateCardView.backgroundColor = .white
|
||||
dateCardView.layer.cornerRadius = 12
|
||||
|
||||
dateTitleLabel.text = "计划永久注销时间"
|
||||
dateTitleLabel.font = .systemFont(ofSize: 14)
|
||||
dateTitleLabel.textColor = AppColor.textSecondary
|
||||
|
||||
dateValueLabel.text = AccountDeletionDateFormatter.displayText(request.scheduledDeletionAt)
|
||||
dateValueLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
dateValueLabel.textColor = AppColor.danger
|
||||
dateValueLabel.textAlignment = .right
|
||||
|
||||
recoveryLabel.text = "在上述时间前再次使用当前手机号和密码登录,确认恢复账号后即可取消注销。"
|
||||
recoveryLabel.font = .systemFont(ofSize: 15)
|
||||
recoveryLabel.textColor = AppColor.textSecondary
|
||||
recoveryLabel.numberOfLines = 0
|
||||
recoveryLabel.textAlignment = .center
|
||||
|
||||
[iconView, titleLabel, descriptionLabel, dateCardView, recoveryLabel, exitButton].forEach(view.addSubview)
|
||||
dateCardView.addSubview(dateTitleLabel)
|
||||
dateCardView.addSubview(dateValueLabel)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(56)
|
||||
make.centerX.equalToSuperview()
|
||||
make.width.height.equalTo(72)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(iconView.snp.bottom).offset(24)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
descriptionLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(10)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
dateCardView.snp.makeConstraints { make in
|
||||
make.top.equalTo(descriptionLabel.snp.bottom).offset(32)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.height.equalTo(72)
|
||||
}
|
||||
dateTitleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
dateValueLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(dateTitleLabel.snp.trailing).offset(12)
|
||||
}
|
||||
recoveryLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(dateCardView.snp.bottom).offset(24)
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
exitButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
exitButton.addTarget(self, action: #selector(exitTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
|
||||
}
|
||||
|
||||
@objc private func exitTapped() {
|
||||
NotificationCenter.default.post(name: NotificationName.userDidLogout, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销页顶部风险提示条。
|
||||
private final class AccountDeletionWarningView: UIView {
|
||||
private let iconView = UIImageView()
|
||||
private let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = AppColor.dangerBackground
|
||||
layer.cornerRadius = 12
|
||||
layer.borderWidth = 1
|
||||
layer.borderColor = AppColor.danger.withAlphaComponent(0.25).cgColor
|
||||
|
||||
iconView.image = UIImage(systemName: "exclamationmark.shield.fill")
|
||||
iconView.tintColor = AppColor.danger
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
|
||||
label.text = "注销后,账号及相关数据将永久删除"
|
||||
label.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
label.textColor = AppColor.danger
|
||||
label.numberOfLines = 0
|
||||
|
||||
addSubview(iconView)
|
||||
addSubview(label)
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(28)
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.equalTo(iconView.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.top.bottom.equalToSuperview().inset(14)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销资产汇总卡片,以列表形式中性展示名称和数量。
|
||||
private final class AccountDeletionAssetCardView: UIView {
|
||||
private let stackView = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
clipsToBounds = true
|
||||
stackView.axis = .vertical
|
||||
addSubview(stackView)
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 使用最新资产快照刷新卡片。
|
||||
func configure(assets: [AccountDeletionAssetSummary]) {
|
||||
stackView.arrangedSubviews.forEach {
|
||||
stackView.removeArrangedSubview($0)
|
||||
$0.removeFromSuperview()
|
||||
}
|
||||
assets.enumerated().forEach { index, asset in
|
||||
stackView.addArrangedSubview(
|
||||
AccountDeletionAssetRowView(asset: asset, showsDivider: index < assets.count - 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销资产列表行,仅显示客观资产名称和当前值。
|
||||
private final class AccountDeletionAssetRowView: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let valueLabel = UILabel()
|
||||
private let divider = UIView()
|
||||
|
||||
init(asset: AccountDeletionAssetSummary, showsDivider: Bool) {
|
||||
super.init(frame: .zero)
|
||||
titleLabel.text = asset.title
|
||||
titleLabel.font = .systemFont(ofSize: 17, weight: .medium)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
|
||||
valueLabel.text = asset.valueText
|
||||
valueLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||
valueLabel.textColor = AppColor.textPrimary
|
||||
valueLabel.textAlignment = .right
|
||||
|
||||
divider.backgroundColor = AppColor.border
|
||||
divider.isHidden = !showsDivider
|
||||
|
||||
[titleLabel, valueLabel, divider].forEach(addSubview)
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(76)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
|
||||
}
|
||||
divider.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销影响说明卡片,展示账号解除、内容删除和7天恢复规则。
|
||||
private final class AccountDeletionConsequenceCardView: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let stackView = UIStackView()
|
||||
private let iconNames = [
|
||||
"person.crop.circle.badge.xmark",
|
||||
"folder",
|
||||
"calendar.badge.exclamationmark",
|
||||
]
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
clipsToBounds = true
|
||||
|
||||
titleLabel.text = "注销后将发生"
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
|
||||
stackView.axis = .vertical
|
||||
addSubview(titleLabel)
|
||||
addSubview(stackView)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 8, right: 16))
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 使用最新注销影响文案刷新卡片。
|
||||
func configure(items: [String]) {
|
||||
stackView.arrangedSubviews.forEach {
|
||||
stackView.removeArrangedSubview($0)
|
||||
$0.removeFromSuperview()
|
||||
}
|
||||
items.enumerated().forEach { index, text in
|
||||
stackView.addArrangedSubview(
|
||||
AccountDeletionConsequenceRowView(
|
||||
iconName: iconNames.indices.contains(index) ? iconNames[index] : "info.circle",
|
||||
text: text,
|
||||
showsDivider: index < items.count - 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销影响说明列表行。
|
||||
private final class AccountDeletionConsequenceRowView: UIView {
|
||||
private let iconBackgroundView = UIView()
|
||||
private let iconView = UIImageView()
|
||||
private let label = UILabel()
|
||||
private let divider = UIView()
|
||||
|
||||
init(iconName: String, text: String, showsDivider: Bool) {
|
||||
super.init(frame: .zero)
|
||||
iconBackgroundView.backgroundColor = AppColor.primaryLight
|
||||
iconBackgroundView.layer.cornerRadius = 18
|
||||
iconView.image = UIImage(systemName: iconName)
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.textColor = AppColor.textPrimary
|
||||
label.numberOfLines = 0
|
||||
divider.backgroundColor = AppColor.border
|
||||
divider.isHidden = !showsDivider
|
||||
|
||||
addSubview(iconBackgroundView)
|
||||
iconBackgroundView.addSubview(iconView)
|
||||
addSubview(label)
|
||||
addSubview(divider)
|
||||
snp.makeConstraints { make in
|
||||
make.height.greaterThanOrEqualTo(58)
|
||||
}
|
||||
iconBackgroundView.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(36)
|
||||
}
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(8)
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.equalTo(iconBackgroundView.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview()
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
divider.snp.makeConstraints { make in
|
||||
make.leading.equalTo(label)
|
||||
make.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 注销流程通用说明条。
|
||||
private final class AccountDeletionNoticeView: UIView {
|
||||
private let iconView = UIImageView()
|
||||
private let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = AppColor.infoBackground
|
||||
layer.cornerRadius = 12
|
||||
addSubview(iconView)
|
||||
addSubview(label)
|
||||
iconView.tintColor = AppColor.primary
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.textColor = AppColor.textSecondary
|
||||
label.numberOfLines = 0
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.equalTo(iconView.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.top.bottom.equalToSuperview().inset(14)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 配置说明条图标与文案。
|
||||
func configure(iconName: String, text: String) {
|
||||
iconView.image = UIImage(systemName: iconName)
|
||||
label.text = text
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,12 @@ final class SettingViewController: BaseViewController {
|
||||
private let contentView = UIView()
|
||||
private let cardView = UIView()
|
||||
private let rowsStack = UIStackView()
|
||||
private let dangerCardView = UIView()
|
||||
private let accountDeletionRow = SettingMenuRow(
|
||||
title: "注销账号",
|
||||
titleColor: AppColor.danger,
|
||||
showsDivider: false
|
||||
)
|
||||
private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
|
||||
private let copyrightLabel = UILabel()
|
||||
|
||||
@@ -41,12 +47,18 @@ final class SettingViewController: BaseViewController {
|
||||
view.addSubview(contentView)
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(rowsStack)
|
||||
contentView.addSubview(dangerCardView)
|
||||
dangerCardView.addSubview(accountDeletionRow)
|
||||
contentView.addSubview(copyrightLabel)
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 12
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
dangerCardView.backgroundColor = .white
|
||||
dangerCardView.layer.cornerRadius = 12
|
||||
dangerCardView.clipsToBounds = true
|
||||
|
||||
rowsStack.axis = .vertical
|
||||
|
||||
copyrightLabel.text = "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||
@@ -68,6 +80,7 @@ final class SettingViewController: BaseViewController {
|
||||
rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
|
||||
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
|
||||
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
|
||||
accountDeletionRow.addTarget(self, action: #selector(accountDeletionTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
@@ -80,6 +93,13 @@ final class SettingViewController: BaseViewController {
|
||||
rowsStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
|
||||
}
|
||||
dangerCardView.snp.makeConstraints { make in
|
||||
make.top.equalTo(cardView.snp.bottom).offset(16)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
accountDeletionRow.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
|
||||
}
|
||||
copyrightLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalToSuperview().offset(-16)
|
||||
@@ -119,6 +139,10 @@ final class SettingViewController: BaseViewController {
|
||||
openAgreement(.privacyPolicy)
|
||||
}
|
||||
|
||||
@objc private func accountDeletionTapped() {
|
||||
navigationController?.pushViewController(AccountDeletionViewController(), animated: true)
|
||||
}
|
||||
|
||||
private func openAgreement(_ kind: SettingAgreementKind) {
|
||||
let destination = viewModel.agreementDestination(for: kind)
|
||||
navigationController?.pushViewController(
|
||||
@@ -141,6 +165,7 @@ final class SettingMenuRow: UIControl {
|
||||
init(
|
||||
title: String,
|
||||
value: String? = nil,
|
||||
titleColor: UIColor = UIColor(hex: 0x4B5563),
|
||||
valueColor: UIColor = AppColor.textPrimary,
|
||||
showsChevron: Bool = true,
|
||||
showsDivider: Bool = true
|
||||
@@ -150,6 +175,7 @@ final class SettingMenuRow: UIControl {
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
titleLabel.text = title
|
||||
titleLabel.textColor = titleColor
|
||||
valueLabel.text = value
|
||||
valueLabel.textColor = valueColor
|
||||
chevronImageView.isHidden = !showsChevron
|
||||
@@ -175,7 +201,6 @@ final class SettingMenuRow: UIControl {
|
||||
|
||||
private func setupUI() {
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
titleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
|
||||
valueLabel.font = .systemFont(ofSize: 14)
|
||||
valueLabel.textColor = AppColor.textPrimary
|
||||
|
||||
@@ -22,8 +22,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
private let freeCountField = UITextField()
|
||||
private let singlePriceField = UITextField()
|
||||
private let packagePriceField = UITextField()
|
||||
private let autoRetouchSectionView = UIView()
|
||||
private let autoRetouchTitleLabel = UILabel()
|
||||
private let autoRetouchDetailLabel = UILabel()
|
||||
private let noRetouchOption = TravelAlbumModeOptionView()
|
||||
private let aiRetouchOption = TravelAlbumModeOptionView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||
|
||||
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
|
||||
self.viewModel = viewModel
|
||||
@@ -31,8 +37,16 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .pageSheet
|
||||
if let sheetPresentationController {
|
||||
sheetPresentationController.detents = [.medium(), .large()]
|
||||
let formDetent = UISheetPresentationController.Detent.Identifier("createTravelAlbumForm")
|
||||
sheetPresentationController.detents = [
|
||||
.custom(identifier: formDetent) { context in
|
||||
min(590, context.maximumDetentValue)
|
||||
},
|
||||
.large(),
|
||||
]
|
||||
sheetPresentationController.selectedDetentIdentifier = formDetent
|
||||
sheetPresentationController.prefersGrabberVisible = false
|
||||
sheetPresentationController.preferredCornerRadius = AppRadius.xl
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +76,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
|
||||
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
|
||||
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
|
||||
configureAutoRetouchSection()
|
||||
|
||||
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
|
||||
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
|
||||
@@ -112,6 +127,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
[freeCountField, singlePriceField, packagePriceField].forEach {
|
||||
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
|
||||
}
|
||||
noRetouchOption.addTarget(self, action: #selector(noRetouchTapped), for: .touchUpInside)
|
||||
aiRetouchOption.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
@@ -148,6 +165,45 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
|
||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
|
||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
|
||||
fieldsStack.addArrangedSubview(autoRetouchSectionView)
|
||||
}
|
||||
|
||||
private func configureAutoRetouchSection() {
|
||||
autoRetouchSectionView.backgroundColor = .white
|
||||
autoRetouchSectionView.layer.cornerRadius = AppRadius.sm
|
||||
autoRetouchSectionView.layer.borderWidth = 1
|
||||
autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor
|
||||
autoRetouchSectionView.clipsToBounds = true
|
||||
|
||||
autoRetouchTitleLabel.text = "修图方式"
|
||||
autoRetouchTitleLabel.font = .app(.bodyMedium)
|
||||
autoRetouchTitleLabel.textColor = AppColor.textPrimary
|
||||
autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板"
|
||||
autoRetouchDetailLabel.font = .app(.caption)
|
||||
autoRetouchDetailLabel.textColor = AppColor.textSecondary
|
||||
|
||||
let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption])
|
||||
optionsStack.axis = .horizontal
|
||||
optionsStack.spacing = AppSpacing.xs
|
||||
optionsStack.distribution = .fillEqually
|
||||
|
||||
autoRetouchSectionView.addSubview(autoRetouchTitleLabel)
|
||||
autoRetouchSectionView.addSubview(autoRetouchDetailLabel)
|
||||
autoRetouchSectionView.addSubview(optionsStack)
|
||||
autoRetouchTitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(AppSpacing.sm)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
}
|
||||
autoRetouchDetailLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(AppSpacing.xxs)
|
||||
make.leading.trailing.equalTo(autoRetouchTitleLabel)
|
||||
}
|
||||
optionsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(AppSpacing.sm)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(AppSpacing.sm)
|
||||
make.height.equalTo(72)
|
||||
}
|
||||
updateAutoRetouchSection()
|
||||
}
|
||||
|
||||
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
|
||||
@@ -188,6 +244,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
freeCount: freeCountField.text ?? "",
|
||||
singlePrice: singlePriceField.text ?? "",
|
||||
packagePrice: packagePriceField.text ?? "",
|
||||
autoRetouchConfiguration: autoRetouchConfiguration,
|
||||
order: nil,
|
||||
api: api
|
||||
)
|
||||
@@ -208,6 +265,43 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func noRetouchTapped() {
|
||||
autoRetouchConfiguration = .disabled
|
||||
updateAutoRetouchSection()
|
||||
}
|
||||
|
||||
@objc private func aiRetouchTapped() {
|
||||
presentTemplatePicker()
|
||||
}
|
||||
|
||||
private func presentTemplatePicker() {
|
||||
guard presentedViewController == nil else { return }
|
||||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
||||
configuration: autoRetouchConfiguration,
|
||||
startsWithModeSelection: false
|
||||
)
|
||||
controller.onConfirm = { [weak self] configuration in
|
||||
guard let self else { return }
|
||||
self.autoRetouchConfiguration = configuration
|
||||
self.updateAutoRetouchSection()
|
||||
}
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func updateAutoRetouchSection() {
|
||||
let isEnabled = autoRetouchConfiguration.isEnabled
|
||||
noRetouchOption.apply(
|
||||
title: TravelAlbumRetouchMode.disabled.title,
|
||||
desc: "保留原图",
|
||||
selected: !isEnabled
|
||||
)
|
||||
aiRetouchOption.apply(
|
||||
title: TravelAlbumRetouchMode.aiRetouch.title,
|
||||
desc: autoRetouchConfiguration.template?.title ?? "点击选模板",
|
||||
selected: isEnabled
|
||||
)
|
||||
}
|
||||
|
||||
private func sanitizeMoneyInput(_ value: String) -> String {
|
||||
var result = ""
|
||||
var hasDot = false
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//
|
||||
// TravelAlbumAutoRetouchSettingSheetViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 自动 AI 修图设置 Sheet,先选修图方式,选择 AI 修图后再选带效果图的模板。
|
||||
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController, UITableViewDelegate {
|
||||
private enum Step: Equatable {
|
||||
case mode
|
||||
case template
|
||||
}
|
||||
|
||||
private enum Item: Hashable {
|
||||
case mode(TravelAlbumRetouchMode)
|
||||
case template(TravelAlbumEditPreset)
|
||||
}
|
||||
|
||||
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
|
||||
var onCancelled: (() -> Void)?
|
||||
|
||||
private let startsWithModeSelection: Bool
|
||||
private let backButton = UIButton(type: .system)
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, Item>!
|
||||
private var step: Step
|
||||
private var pendingConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||
private var selectedItem: Item?
|
||||
private var previewImages: [TravelAlbumEditPreset.Effect: UIImage] = [:]
|
||||
private var didConfirm = false
|
||||
|
||||
/// 创建自动修图设置 Sheet。
|
||||
/// - Parameters:
|
||||
/// - configuration: 当前配置,用于回显已选方式和模板。
|
||||
/// - startsWithModeSelection: 是否先展示“不修图 / AI 修图”两个一级选项。
|
||||
init(
|
||||
configuration: TravelAlbumAutoRetouchConfiguration,
|
||||
startsWithModeSelection: Bool
|
||||
) {
|
||||
self.startsWithModeSelection = startsWithModeSelection
|
||||
step = startsWithModeSelection ? .mode : .template
|
||||
pendingConfiguration = configuration
|
||||
if startsWithModeSelection {
|
||||
selectedItem = .mode(configuration.isEnabled ? .aiRetouch : .disabled)
|
||||
} else if let template = configuration.template {
|
||||
selectedItem = .template(template)
|
||||
}
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .pageSheet
|
||||
if let sheetPresentationController {
|
||||
let identifier = UISheetPresentationController.Detent.Identifier("travelAlbumAutoRetouchSetting")
|
||||
sheetPresentationController.detents = [
|
||||
.custom(identifier: identifier) { context in
|
||||
min(660, context.maximumDetentValue)
|
||||
},
|
||||
.large(),
|
||||
]
|
||||
sheetPresentationController.selectedDetentIdentifier = identifier
|
||||
sheetPresentationController.prefersGrabberVisible = false
|
||||
sheetPresentationController.preferredCornerRadius = AppRadius.xl
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = .white
|
||||
|
||||
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||
backButton.tintColor = AppColor.textPrimary
|
||||
backButton.accessibilityLabel = "返回修图方式"
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
subtitleLabel.font = .app(.caption)
|
||||
subtitleLabel.textColor = AppColor.textSecondary
|
||||
subtitleLabel.textAlignment = .center
|
||||
subtitleLabel.numberOfLines = 0
|
||||
|
||||
tableView.backgroundColor = .white
|
||||
tableView.separatorStyle = .none
|
||||
tableView.showsVerticalScrollIndicator = false
|
||||
tableView.delegate = self
|
||||
tableView.register(
|
||||
TravelAlbumAutoRetouchOptionCell.self,
|
||||
forCellReuseIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier
|
||||
)
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Int, Item>(tableView: tableView) {
|
||||
[weak self] tableView, indexPath, item in
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: TravelAlbumAutoRetouchOptionCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumAutoRetouchOptionCell
|
||||
guard let self else { return cell }
|
||||
switch item {
|
||||
case .mode(let mode):
|
||||
let iconName = mode == .disabled ? "photo" : "wand.and.stars"
|
||||
let detail = mode == .disabled
|
||||
? "保留照片原效果"
|
||||
: (self.pendingConfiguration.template.map { "当前模板:\($0.title)" } ?? "选择后需再选一个修图模板")
|
||||
cell.apply(
|
||||
title: mode.title,
|
||||
detail: detail,
|
||||
previewImage: UIImage(systemName: iconName),
|
||||
usesTemplateImage: true,
|
||||
selected: self.selectedItem == item
|
||||
)
|
||||
case .template(let preset):
|
||||
cell.apply(
|
||||
title: preset.title,
|
||||
detail: preset.autoRetouchEffectDescription,
|
||||
previewImage: self.previewImage(for: preset),
|
||||
usesTemplateImage: false,
|
||||
selected: self.selectedItem == item
|
||||
)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
configureActionButton(cancelButton, title: "取消", isPrimary: false)
|
||||
configureActionButton(confirmButton, title: "确定", isPrimary: true)
|
||||
let buttonStack = UIStackView(arrangedSubviews: [cancelButton, confirmButton])
|
||||
buttonStack.axis = .horizontal
|
||||
buttonStack.spacing = AppSpacing.sm
|
||||
buttonStack.distribution = .fillEqually
|
||||
|
||||
view.addSubview(backButton)
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(subtitleLabel)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(buttonStack)
|
||||
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(AppSpacing.md)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.size.equalTo(AppSpacing.minTouchTarget)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(AppSpacing.lg)
|
||||
make.leading.trailing.equalToSuperview().inset(60)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.xs)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.lg)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(AppSpacing.md)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.bottom.equalTo(buttonStack.snp.top).offset(-AppSpacing.sm)
|
||||
}
|
||||
buttonStack.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-AppSpacing.md)
|
||||
make.height.equalTo(AppSpacing.minTouchTarget)
|
||||
}
|
||||
|
||||
refreshContent()
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
if isBeingDismissed, !didConfirm {
|
||||
onCancelled?()
|
||||
}
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
step == .mode ? 80 : 108
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch item {
|
||||
case .mode(.disabled):
|
||||
pendingConfiguration = .disabled
|
||||
selectedItem = item
|
||||
applySnapshot()
|
||||
updateConfirmButton()
|
||||
case .mode(.aiRetouch):
|
||||
step = .template
|
||||
selectedItem = pendingConfiguration.template.map(Item.template)
|
||||
refreshContent()
|
||||
case .template(let preset):
|
||||
pendingConfiguration = .enabled(templateID: preset.id)
|
||||
selectedItem = item
|
||||
applySnapshot()
|
||||
updateConfirmButton()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshContent() {
|
||||
switch step {
|
||||
case .mode:
|
||||
titleLabel.text = "选择修图方式"
|
||||
subtitleLabel.text = "照片上传前可选择保留原图,或使用 AI 自动修图"
|
||||
backButton.isHidden = true
|
||||
case .template:
|
||||
titleLabel.text = "选择修图模板"
|
||||
subtitleLabel.text = "缩略图为模板实际效果,后续上传的照片将自动套用"
|
||||
backButton.isHidden = !startsWithModeSelection
|
||||
}
|
||||
applySnapshot()
|
||||
updateConfirmButton()
|
||||
}
|
||||
|
||||
private func applySnapshot() {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
|
||||
snapshot.appendSections([0])
|
||||
switch step {
|
||||
case .mode:
|
||||
snapshot.appendItems(TravelAlbumRetouchMode.allCases.map(Item.mode))
|
||||
case .template:
|
||||
snapshot.appendItems(TravelAlbumEditPreset.autoRetouchOptions.map(Item.template))
|
||||
}
|
||||
|
||||
// 选中状态存放在页面状态中,item 本身的标识不会变化。
|
||||
// Diffable Data Source 不会主动重新配置相同 item,因此需显式刷新仍在当前列表中的选项。
|
||||
let currentItems = Set(dataSource.snapshot().itemIdentifiers)
|
||||
let retainedItems = snapshot.itemIdentifiers.filter(currentItems.contains)
|
||||
snapshot.reconfigureItems(retainedItems)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
private func updateConfirmButton() {
|
||||
let isEnabled: Bool
|
||||
switch selectedItem {
|
||||
case .mode(.disabled):
|
||||
isEnabled = step == .mode
|
||||
case .mode(.aiRetouch):
|
||||
isEnabled = step == .mode && pendingConfiguration.isEnabled && pendingConfiguration.isValid
|
||||
case .template:
|
||||
isEnabled = step == .template && pendingConfiguration.isEnabled && pendingConfiguration.isValid
|
||||
case nil:
|
||||
isEnabled = false
|
||||
}
|
||||
confirmButton.isEnabled = isEnabled
|
||||
confirmButton.alpha = isEnabled ? 1 : 0.45
|
||||
}
|
||||
|
||||
private func configureActionButton(_ button: UIButton, title: String, isPrimary: Bool) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(isPrimary ? .white : AppColor.textSecondary, for: .normal)
|
||||
button.titleLabel?.font = .app(.subtitle)
|
||||
button.backgroundColor = isPrimary ? AppColor.primary : AppColor.inputBackground
|
||||
button.layer.cornerRadius = AppRadius.md
|
||||
}
|
||||
|
||||
private func previewImage(for preset: TravelAlbumEditPreset) -> UIImage? {
|
||||
if let image = previewImages[preset.effect] {
|
||||
return image
|
||||
}
|
||||
guard let source = makePreviewSourceImage() else { return nil }
|
||||
let image = TravelAlbumAIEditImageProcessor.render(effect: preset.effect, source: source)
|
||||
previewImages[preset.effect] = image
|
||||
return image
|
||||
}
|
||||
|
||||
private func makePreviewSourceImage() -> UIImage? {
|
||||
guard let source = UIImage(named: "purchased_lakeside_flowers") else { return nil }
|
||||
let size = CGSize(width: 176, height: 176)
|
||||
return UIGraphicsImageRenderer(size: size).image { _ in
|
||||
let scale = max(size.width / source.size.width, size.height / source.size.height)
|
||||
let drawSize = CGSize(width: source.size.width * scale, height: source.size.height * scale)
|
||||
let origin = CGPoint(x: (size.width - drawSize.width) / 2, y: (size.height - drawSize.height) / 2)
|
||||
source.draw(in: CGRect(origin: origin, size: drawSize))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func backTapped() {
|
||||
guard startsWithModeSelection else { return }
|
||||
step = .mode
|
||||
selectedItem = .mode(pendingConfiguration.isEnabled ? .aiRetouch : .disabled)
|
||||
refreshContent()
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard confirmButton.isEnabled else { return }
|
||||
didConfirm = true
|
||||
onConfirm?(pendingConfiguration)
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动修图选项行,一级方式显示图标,模板显示本地生成的真实效果缩略图。
|
||||
private final class TravelAlbumAutoRetouchOptionCell: UITableViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumAutoRetouchOptionCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let previewImageView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let selectionImageView = UIImageView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
|
||||
cardView.layer.cornerRadius = AppRadius.sm
|
||||
cardView.layer.borderWidth = 1
|
||||
previewImageView.clipsToBounds = true
|
||||
previewImageView.layer.cornerRadius = AppRadius.xs
|
||||
titleLabel.font = .app(.bodyMedium)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
detailLabel.font = .app(.caption)
|
||||
detailLabel.textColor = AppColor.textTertiary
|
||||
detailLabel.numberOfLines = 2
|
||||
selectionImageView.contentMode = .scaleAspectFit
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(previewImageView)
|
||||
cardView.addSubview(titleLabel)
|
||||
cardView.addSubview(detailLabel)
|
||||
cardView.addSubview(selectionImageView)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0))
|
||||
}
|
||||
previewImageView.snp.makeConstraints { make in
|
||||
make.top.bottom.leading.equalToSuperview().inset(8)
|
||||
make.width.equalTo(88)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(previewImageView.snp.trailing).offset(AppSpacing.sm)
|
||||
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-AppSpacing.sm)
|
||||
make.bottom.equalTo(cardView.snp.centerY).offset(-2)
|
||||
}
|
||||
detailLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(cardView.snp.centerY).offset(2)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
}
|
||||
selectionImageView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-AppSpacing.md)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(22)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 刷新选项文案、效果图与单选外观。
|
||||
func apply(
|
||||
title: String,
|
||||
detail: String,
|
||||
previewImage: UIImage?,
|
||||
usesTemplateImage: Bool,
|
||||
selected: Bool
|
||||
) {
|
||||
titleLabel.text = title
|
||||
detailLabel.text = detail
|
||||
previewImageView.image = usesTemplateImage
|
||||
? previewImage?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 28, weight: .medium))
|
||||
: previewImage
|
||||
previewImageView.contentMode = usesTemplateImage ? .center : .scaleAspectFill
|
||||
previewImageView.tintColor = AppColor.primary
|
||||
previewImageView.backgroundColor = usesTemplateImage ? AppColor.primaryLight : AppColor.pageBackground
|
||||
cardView.backgroundColor = selected ? AppColor.primaryLight : .white
|
||||
cardView.layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
|
||||
selectionImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
selectionImageView.tintColor = selected ? AppColor.primary : AppColor.textTertiary
|
||||
accessibilityLabel = "\(title),\(detail)"
|
||||
accessibilityValue = selected ? "已选择" : "未选择"
|
||||
}
|
||||
}
|
||||
|
||||
private extension TravelAlbumEditPreset {
|
||||
/// 模板效果的简短说明,与缩略图一起帮助用户判断效果。
|
||||
var autoRetouchEffectDescription: String {
|
||||
switch effect {
|
||||
case .original:
|
||||
return "保留原图色彩"
|
||||
case .portrait:
|
||||
return "明亮柔和的人像质感"
|
||||
case .vintage:
|
||||
return "低饱和复古色调"
|
||||
case .brocade:
|
||||
return "鲜活通透的旅拍风格"
|
||||
case .distantMountain:
|
||||
return "自然淡雅的远山色调"
|
||||
case .mist:
|
||||
return "轻雾低对比氛围"
|
||||
case .summer:
|
||||
return "温暖明亮的油画色彩"
|
||||
case .rich:
|
||||
return "高饱和浓郁油画质感"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
private let refreshButton = UIButton(type: .system)
|
||||
private let helpLabel = UILabel()
|
||||
private let chipsStack = UIStackView()
|
||||
private let retouchButton = WiredTransferSettingChipButton()
|
||||
private let retouchButton = WiredTransferSettingChipButton(showsChevron: true)
|
||||
private let formatButton = WiredTransferSettingChipButton()
|
||||
private let modeButton = WiredTransferSettingChipButton(showsChevron: true)
|
||||
private let settingsStatsDivider = UIView()
|
||||
@@ -161,7 +161,6 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
[retouchButton, formatButton, modeButton].forEach {
|
||||
chipsStack.addArrangedSubview($0)
|
||||
}
|
||||
retouchButton.isUserInteractionEnabled = false
|
||||
formatButton.isUserInteractionEnabled = false
|
||||
settingsStatsDivider.backgroundColor = AppColor.border
|
||||
|
||||
@@ -387,6 +386,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
||||
retouchButton.addTarget(self, action: #selector(retouchTapped), for: .touchUpInside)
|
||||
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
|
||||
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
|
||||
albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside)
|
||||
@@ -567,6 +567,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
let selected = viewModel.selectedPhotoIds.contains(item.id)
|
||||
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
|
||||
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
|
||||
cell.onRetryRetouch = { [weak self] in self?.viewModel.retryAutoRetouch(photoId: item.id) }
|
||||
cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) }
|
||||
}
|
||||
|
||||
@@ -711,6 +712,17 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
viewModel.refreshCameraFiles()
|
||||
}
|
||||
|
||||
@objc private func retouchTapped() {
|
||||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
||||
configuration: viewModel.autoRetouchConfiguration,
|
||||
startsWithModeSelection: true
|
||||
)
|
||||
controller.onConfirm = { [weak self] configuration in
|
||||
self?.viewModel.updateAutoRetouchConfiguration(configuration)
|
||||
}
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
@objc private func batchTapped() {
|
||||
viewModel.onBatchUploadButtonClick()
|
||||
}
|
||||
@@ -1126,6 +1138,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
|
||||
private let selectionIconView = UIImageView()
|
||||
private let imageView = UIImageView()
|
||||
private let retouchBadgeLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let titleLabel = UILabel()
|
||||
private let sizeLabel = UILabel()
|
||||
@@ -1134,6 +1147,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
private let separatorView = UIView()
|
||||
|
||||
var onRetry: (() -> Void)?
|
||||
var onRetryRetouch: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -1151,6 +1165,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
imageView.kf.cancelDownloadTask()
|
||||
imageView.image = nil
|
||||
onRetry = nil
|
||||
onRetryRetouch = nil
|
||||
onDelete = nil
|
||||
menuButton.menu = nil
|
||||
}
|
||||
@@ -1178,16 +1193,21 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
titleLabel.alpha = rowAlpha
|
||||
sizeLabel.text = item.fileSizeText
|
||||
sizeLabel.alpha = rowAlpha
|
||||
statusLabel.text = statusText(item.status)
|
||||
statusLabel.textColor = statusTextColor(item.status)
|
||||
statusLabel.backgroundColor = statusBackgroundColor(item.status)
|
||||
progressView.isHidden = item.status != .uploading && item.status != .transferring
|
||||
progressView.progress = Float(item.progress) / 100.0
|
||||
statusLabel.text = statusText(item)
|
||||
statusLabel.textColor = statusTextColor(item)
|
||||
statusLabel.backgroundColor = statusBackgroundColor(item)
|
||||
retouchBadgeLabel.isHidden = item.autoRetouchState != .completed
|
||||
let isProcessing = item.autoRetouchState == .processing
|
||||
progressView.isHidden = !isProcessing && item.status != .uploading && item.status != .transferring
|
||||
progressView.progress = isProcessing ? 0.62 : Float(item.progress) / 100.0
|
||||
selectionIconView.isHidden = !selectionMode
|
||||
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
|
||||
menuButton.isHidden = selectionMode
|
||||
menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending)
|
||||
menuButton.menu = makeMenu(
|
||||
canRetryUpload: item.status == .failed || item.status == .pending,
|
||||
canRetryRetouch: item.autoRetouchState == .failed
|
||||
)
|
||||
updateImageConstraints(selectionMode: selectionMode)
|
||||
}
|
||||
|
||||
@@ -1197,6 +1217,14 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
imageView.layer.cornerRadius = 6
|
||||
retouchBadgeLabel.text = "修"
|
||||
retouchBadgeLabel.font = .systemFont(ofSize: 9, weight: .semibold)
|
||||
retouchBadgeLabel.textColor = .white
|
||||
retouchBadgeLabel.textAlignment = .center
|
||||
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x7C3AED)
|
||||
retouchBadgeLabel.layer.cornerRadius = 8
|
||||
retouchBadgeLabel.clipsToBounds = true
|
||||
retouchBadgeLabel.isHidden = true
|
||||
statusLabel.font = .systemFont(ofSize: 9)
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.layer.cornerRadius = 3
|
||||
@@ -1219,6 +1247,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
|
||||
contentView.addSubview(selectionIconView)
|
||||
contentView.addSubview(imageView)
|
||||
contentView.addSubview(retouchBadgeLabel)
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(statusLabel)
|
||||
contentView.addSubview(sizeLabel)
|
||||
@@ -1232,6 +1261,11 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
updateImageConstraints(selectionMode: true)
|
||||
retouchBadgeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(imageView).offset(2)
|
||||
make.trailing.equalTo(imageView).offset(-2)
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(imageView).offset(1)
|
||||
make.leading.equalTo(imageView.snp.trailing).offset(8)
|
||||
@@ -1266,15 +1300,19 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMenu(canRetry: Bool) -> UIMenu {
|
||||
private func makeMenu(canRetryUpload: Bool, canRetryRetouch: Bool) -> UIMenu {
|
||||
let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
|
||||
self?.onRetry?()
|
||||
}
|
||||
retry.attributes = canRetry ? [] : [.disabled]
|
||||
retry.attributes = canRetryUpload ? [] : [.disabled]
|
||||
let retryRetouch = UIAction(title: "重新修图", image: UIImage(systemName: "wand.and.stars")) { [weak self] _ in
|
||||
self?.onRetryRetouch?()
|
||||
}
|
||||
retryRetouch.attributes = canRetryRetouch ? [] : [.disabled]
|
||||
let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
||||
self?.onDelete?()
|
||||
}
|
||||
return UIMenu(children: [retry, delete])
|
||||
return UIMenu(children: [retry, retryRetouch, delete])
|
||||
}
|
||||
|
||||
private func updateImageConstraints(selectionMode: Bool) {
|
||||
@@ -1290,8 +1328,13 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
private func statusText(_ status: TravelAlbumOTGUploadStatus) -> String {
|
||||
switch status {
|
||||
private func statusText(_ item: TravelAlbumOTGPhotoItem) -> String {
|
||||
switch item.autoRetouchState {
|
||||
case .processing: return "修图中"
|
||||
case .failed: return "修图失败"
|
||||
case .none, .completed: break
|
||||
}
|
||||
switch item.status {
|
||||
case .pending: return "待上传"
|
||||
case .transferring: return "传输中"
|
||||
case .uploading: return "上传中"
|
||||
@@ -1300,8 +1343,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
private func statusTextColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
|
||||
switch status {
|
||||
private func statusTextColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor {
|
||||
if item.autoRetouchState == .processing { return AppColor.primary }
|
||||
if item.autoRetouchState == .failed { return AppColor.danger }
|
||||
switch item.status {
|
||||
case .pending: return AppColor.textSecondary
|
||||
case .transferring, .uploading: return AppColor.primary
|
||||
case .uploaded: return UIColor(hex: 0x16A34A)
|
||||
@@ -1309,8 +1354,10 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
}
|
||||
}
|
||||
|
||||
private func statusBackgroundColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor {
|
||||
switch status {
|
||||
private func statusBackgroundColor(_ item: TravelAlbumOTGPhotoItem) -> UIColor {
|
||||
if item.autoRetouchState == .processing { return AppColor.primary.withAlphaComponent(0.12) }
|
||||
if item.autoRetouchState == .failed { return AppColor.danger.withAlphaComponent(0.12) }
|
||||
switch item.status {
|
||||
case .pending:
|
||||
return AppColor.pageBackground
|
||||
case .transferring, .uploading:
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import MAMapKit
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
import MapKit
|
||||
#else
|
||||
import MAMapKit
|
||||
#endif
|
||||
|
||||
/// 我的举报列表页,支持按状态筛选并进入举报详情。
|
||||
final class WildPhotographerReportListViewController: BaseViewController {
|
||||
private let viewModel: WildPhotographerReportListViewModel
|
||||
@@ -1797,6 +1802,49 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
||||
}
|
||||
|
||||
/// 举报详情页地图预览,绘制简化道路、路径和定位点。
|
||||
#if targetEnvironment(simulator)
|
||||
private final class WildReportDetailMapPreviewView: UIView {
|
||||
private let mapView: MKMapView
|
||||
|
||||
init(scenicName: String, detailAddress: String, coordinate: CLLocationCoordinate2D?) {
|
||||
mapView = MKMapView(frame: .zero)
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = AppColor.pageBackgroundSoft
|
||||
layer.cornerRadius = 10
|
||||
clipsToBounds = true
|
||||
|
||||
mapView.showsCompass = false
|
||||
mapView.showsScale = false
|
||||
mapView.isScrollEnabled = false
|
||||
mapView.isZoomEnabled = false
|
||||
mapView.isRotateEnabled = false
|
||||
mapView.isPitchEnabled = false
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
guard let coordinate else { return }
|
||||
let annotation = MKPointAnnotation()
|
||||
annotation.coordinate = coordinate
|
||||
annotation.title = scenicName
|
||||
annotation.subtitle = detailAddress
|
||||
mapView.addAnnotation(annotation)
|
||||
mapView.setRegion(
|
||||
MKCoordinateRegion(
|
||||
center: coordinate,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.006, longitudeDelta: 0.006)
|
||||
),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
#else
|
||||
private final class WildReportDetailMapPreviewView: UIView {
|
||||
private let mapView: MAMapView
|
||||
|
||||
@@ -1834,6 +1882,7 @@ private final class WildReportDetailMapPreviewView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// 举报详情页处理进度时间线单行。
|
||||
private final class WildReportDetailTimelineRowView: UIView {
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
#if __has_include(<AMapFoundationKit/AMapFoundationKit.h>)
|
||||
#if !TARGET_OS_SIMULATOR && __has_include(<AMapFoundationKit/AMapFoundationKit.h>)
|
||||
#import <AMapFoundationKit/AMapFoundationKit.h>
|
||||
#endif
|
||||
|
||||
#if __has_include(<AMapLocationKit/AMapLocationKit.h>)
|
||||
#if !TARGET_OS_SIMULATOR && __has_include(<AMapLocationKit/AMapLocationKit.h>)
|
||||
#import <AMapLocationKit/AMapLocationKit.h>
|
||||
#endif
|
||||
|
||||
#if __has_include(<MAMapKit/MAMapKit.h>)
|
||||
#if !TARGET_OS_SIMULATOR && __has_include(<MAMapKit/MAMapKit.h>)
|
||||
#import <MAMapKit/MAMapKit.h>
|
||||
#endif
|
||||
|
||||
#if __has_include(<AMapSearchKit/AMapSearchKit.h>)
|
||||
#if !TARGET_OS_SIMULATOR && __has_include(<AMapSearchKit/AMapSearchKit.h>)
|
||||
#import <AMapSearchKit/AMapSearchKit.h>
|
||||
#endif
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 注销账号功能的业务规则测试,覆盖资产确认、七天冷静期与账号恢复。
|
||||
final class AccountDeletionFeatureTests: XCTestCase {
|
||||
private var suiteName = ""
|
||||
private var defaults: UserDefaults!
|
||||
private var currentDate: Date!
|
||||
private var service: AccountDeletionMockService!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "AccountDeletionFeatureTests.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
currentDate = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
service = AccountDeletionMockService(
|
||||
defaults: defaults,
|
||||
now: { [weak self] in self?.currentDate ?? Date() },
|
||||
responseDelayNanoseconds: 0
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
service = nil
|
||||
currentDate = nil
|
||||
defaults = nil
|
||||
suiteName = ""
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// 预检查应返回全部资产项,并保持中性展示文案。
|
||||
func testPrecheckReturnsAllAccountAssetsWithoutAbandonmentLabel() async throws {
|
||||
let precheck = try await service.loadPrecheck(username: "18651857230")
|
||||
|
||||
XCTAssertEqual(precheck.assets.map(\.kind), AccountDeletionAssetSummary.Kind.allCases)
|
||||
XCTAssertEqual(precheck.assets.map(\.valueText), ["¥286.50", "36个", "4个", "8.6 GB"])
|
||||
XCTAssertFalse(precheck.assets.map(\.title).contains(where: { $0.contains("可放弃") }))
|
||||
XCTAssertFalse(precheck.assets.map(\.valueText).contains(where: { $0.contains("可放弃") }))
|
||||
}
|
||||
|
||||
/// 用户勾选确认前不允许进入验证页。
|
||||
func testViewModelRequiresExplicitAssetAcknowledgement() async throws {
|
||||
let viewModel = AccountDeletionViewModel(username: "18651857230", service: service)
|
||||
|
||||
XCTAssertFalse(viewModel.isContinueEnabled)
|
||||
await viewModel.load()
|
||||
XCTAssertFalse(viewModel.isContinueEnabled)
|
||||
XCTAssertTrue(viewModel.isContinueButtonEnabled)
|
||||
|
||||
XCTAssertThrowsError(try viewModel.makeVerificationContext()) { error in
|
||||
XCTAssertEqual(error as? AccountDeletionError, .incompleteAcknowledgement)
|
||||
}
|
||||
|
||||
viewModel.toggleAcknowledgement()
|
||||
XCTAssertTrue(viewModel.isContinueEnabled)
|
||||
|
||||
let context = try viewModel.makeVerificationContext()
|
||||
XCTAssertEqual(context.maskedPhone, "186****7230")
|
||||
XCTAssertEqual(Set(context.assets.map(\.kind)), Set(AccountDeletionAssetSummary.Kind.allCases))
|
||||
}
|
||||
|
||||
/// 注销申请必须校验验证码,并确认全部资产项。
|
||||
func testSubmitRequiresCorrectCodeAndAllAssetKinds() async throws {
|
||||
do {
|
||||
_ = try await service.submitDeletion(
|
||||
username: "18651857230",
|
||||
verificationCode: "000000",
|
||||
acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases),
|
||||
clientRequestID: UUID()
|
||||
)
|
||||
XCTFail("错误验证码不应提交成功")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? AccountDeletionError, .invalidVerificationCode)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await service.submitDeletion(
|
||||
username: "18651857230",
|
||||
verificationCode: AccountDeletionMockService.verificationCode,
|
||||
acknowledgedAssetKinds: [.wallet, .works, .projects],
|
||||
clientRequestID: UUID()
|
||||
)
|
||||
XCTFail("未确认全部资产项不应提交成功")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? AccountDeletionError, .incompleteAcknowledgement)
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交后进入七天冷静期,期间再次登录可以取消注销。
|
||||
func testPendingDeletionCanBeCanceledWithinSevenDays() async throws {
|
||||
let request = try await submitDeletion(username: "18651857230")
|
||||
|
||||
XCTAssertEqual(request.scheduledDeletionAt, currentDate.addingTimeInterval(7 * 24 * 60 * 60))
|
||||
guard case let .pending(savedRequest) = service.loginState(for: "18651857230") else {
|
||||
return XCTFail("应进入待注销状态")
|
||||
}
|
||||
XCTAssertEqual(savedRequest, request)
|
||||
|
||||
currentDate = currentDate.addingTimeInterval(6 * 24 * 60 * 60)
|
||||
_ = try service.cancelDeletion(username: "18651857230")
|
||||
XCTAssertEqual(service.loginState(for: "18651857230"), .none)
|
||||
}
|
||||
|
||||
/// 七天后账号进入已注销状态,不能再通过登录恢复。
|
||||
func testDeletionBecomesCompletedAfterSevenDays() async throws {
|
||||
_ = try await submitDeletion(username: "18651857230")
|
||||
currentDate = currentDate.addingTimeInterval(7 * 24 * 60 * 60 + 1)
|
||||
|
||||
guard case .completed = service.loginState(for: "18651857230") else {
|
||||
return XCTFail("冷静期结束后应为已注销状态")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try service.cancelDeletion(username: "18651857230")
|
||||
XCTFail("已超过七天不应恢复账号")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? AccountDeletionError, .cancellationExpired)
|
||||
}
|
||||
}
|
||||
|
||||
/// 手机号展示仅保留前三位和后四位。
|
||||
func testPhoneMaskingNormalizesStoredUsername() {
|
||||
XCTAssertEqual(AccountDeletionIdentity.normalizedUsername("+86 186-5185-7230"), "18651857230")
|
||||
XCTAssertEqual(AccountDeletionIdentity.maskedPhone("+86 186-5185-7230"), "186****7230")
|
||||
}
|
||||
|
||||
private func submitDeletion(username: String) async throws -> AccountDeletionRequest {
|
||||
try await service.submitDeletion(
|
||||
username: username,
|
||||
verificationCode: AccountDeletionMockService.verificationCode,
|
||||
acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases),
|
||||
clientRequestID: UUID()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ final class AuthModelsTests: XCTestCase {
|
||||
XCTAssertEqual(response.token, "person-temp-token")
|
||||
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
|
||||
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
|
||||
XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"])
|
||||
}
|
||||
|
||||
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
|
||||
@@ -137,4 +138,39 @@ final class AuthModelsTests: XCTestCase {
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
|
||||
}
|
||||
|
||||
func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws {
|
||||
let json = """
|
||||
{
|
||||
"token": "person-temp-token",
|
||||
"scenic_users": [
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"ss_user_id": 101,
|
||||
"username": "backend_scenic_account",
|
||||
"user_name": "backend_scenic_unique_id",
|
||||
"real_name": "景区真实姓名"
|
||||
}
|
||||
],
|
||||
"store_users": [
|
||||
{
|
||||
"account_type": "store_user",
|
||||
"store_user_id": 201,
|
||||
"username": "backend_store_account",
|
||||
"user_name": "backend_store_unique_id",
|
||||
"real_name": "门店真实姓名"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let response = try JSONDecoder().decode(V9AuthResponse.self, from: json)
|
||||
|
||||
XCTAssertEqual(response.scenicUsers.first?.realName, "景区真实姓名")
|
||||
XCTAssertEqual(response.scenicUsers.first?.displayName, "景区真实姓名")
|
||||
XCTAssertEqual(response.scenicUsers.first?.toAccountSwitchAccount().title, "景区真实姓名")
|
||||
XCTAssertEqual(response.storeUsers.first?.realName, "门店真实姓名")
|
||||
XCTAssertEqual(response.storeUsers.first?.displayName, "门店真实姓名")
|
||||
XCTAssertEqual(response.storeUsers.first?.toAccountSwitchAccount().title, "门店真实姓名")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//
|
||||
// CommissionRateLogTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 获客员分成比例修改记录模型、接口与分页行为测试。
|
||||
@MainActor
|
||||
final class CommissionRateLogTests: XCTestCase {
|
||||
func testModelDecodesCompleteRecordAndBuildsBusinessDisplayText() throws {
|
||||
let data = """
|
||||
{
|
||||
"list": [{
|
||||
"id": 1,
|
||||
"binding_id": 10,
|
||||
"sale_user_id": 456,
|
||||
"store_user_id": 732,
|
||||
"before_rate": 10,
|
||||
"before_rate_label": "10%(旧)",
|
||||
"after_rate": 20,
|
||||
"after_rate_label": "20%(新)",
|
||||
"operator_type": "store_user",
|
||||
"operator_id": 732,
|
||||
"operator_name": "李师傅",
|
||||
"remark": " 调整合作比例 ",
|
||||
"created_at": "2026-07-23 11:00:00"
|
||||
}],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 10
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let response = try JSONDecoder().decode(CommissionRateLogListResponse.self, from: data)
|
||||
let item = try XCTUnwrap(response.list.first)
|
||||
|
||||
XCTAssertEqual(response.pageSize, 10)
|
||||
XCTAssertEqual(item.bindingId, 10)
|
||||
XCTAssertEqual(item.saleUserId, 456)
|
||||
XCTAssertEqual(item.storeUserId, 732)
|
||||
XCTAssertEqual(item.operatorType, "store_user")
|
||||
XCTAssertEqual(item.operatorId, 732)
|
||||
XCTAssertEqual(item.displayBeforeRate, "10%(旧)")
|
||||
XCTAssertEqual(item.displayAfterRate, "20%(新)")
|
||||
XCTAssertEqual(item.metadataLine, "2026-07-23 11:00")
|
||||
XCTAssertEqual(item.displayRemark, "调整合作比例")
|
||||
}
|
||||
|
||||
func testDisplayFallsBackToNumericRateUnknownOperatorAndRawTime() throws {
|
||||
let data = """
|
||||
{
|
||||
"list": [{
|
||||
"id": 2,
|
||||
"binding_id": 0,
|
||||
"sale_user_id": 456,
|
||||
"store_user_id": 0,
|
||||
"before_rate": 5,
|
||||
"before_rate_label": "",
|
||||
"after_rate": 15,
|
||||
"after_rate_label": " ",
|
||||
"operator_type": "",
|
||||
"operator_id": 0,
|
||||
"operator_name": "",
|
||||
"remark": " ",
|
||||
"created_at": "服务端时间"
|
||||
}],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"page_size": 10
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let item = try JSONDecoder().decode(CommissionRateLogListResponse.self, from: data).list[0]
|
||||
|
||||
XCTAssertEqual(item.displayBeforeRate, "5%")
|
||||
XCTAssertEqual(item.displayAfterRate, "15%")
|
||||
XCTAssertEqual(item.metadataLine, "服务端时间")
|
||||
XCTAssertNil(item.displayRemark)
|
||||
}
|
||||
|
||||
func testAPIBuildsPathAndClampsPaginationParameters() async throws {
|
||||
let session = MockURLSession(responses: [envelope(list: [], total: 0, page: 1)])
|
||||
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
_ = try await api.saleUserCommissionRateLogs(saleUserId: 456, page: 0, pageSize: 99)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/sale-user/commission-rate/logs")
|
||||
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(query?.first { $0.name == "sale_user_id" }?.value, "456")
|
||||
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "1")
|
||||
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "50")
|
||||
}
|
||||
|
||||
func testViewModelRetriesFailedPageAndDeduplicatesItems() async throws {
|
||||
let page1 = envelope(
|
||||
list: [
|
||||
record(id: 1, before: 10, after: 20),
|
||||
record(id: 1, before: 10, after: 20),
|
||||
record(id: 2, before: 20, after: 30),
|
||||
],
|
||||
total: 3,
|
||||
page: 1
|
||||
)
|
||||
let page2 = envelope(
|
||||
list: [
|
||||
record(id: 2, before: 20, after: 30),
|
||||
record(id: 3, before: 30, after: 40),
|
||||
],
|
||||
total: 3,
|
||||
page: 2
|
||||
)
|
||||
let response = HTTPURLResponse(
|
||||
url: URL(string: "https://api-test.zhifly.cn/mock")!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
let session = MockURLSession(results: [
|
||||
.success((page1, response)),
|
||||
.failure(URLError(.timedOut)),
|
||||
.success((page2, response)),
|
||||
])
|
||||
let api = OrderAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = CommissionRateLogViewModel(saleUserId: 456)
|
||||
|
||||
await viewModel.refresh(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
|
||||
XCTAssertTrue(viewModel.canLoadMore)
|
||||
XCTAssertFalse(viewModel.isLoadingMore)
|
||||
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2, 3])
|
||||
XCTAssertFalse(viewModel.canLoadMore)
|
||||
let requestedPages = session.requests.compactMap { request in
|
||||
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.first { $0.name == "page" }?
|
||||
.value
|
||||
}
|
||||
XCTAssertEqual(requestedPages, ["1", "2", "2"])
|
||||
}
|
||||
|
||||
func testTimelineCellOnlyMarksLatestRecord() {
|
||||
let item = CommissionRateLogEntity(
|
||||
id: 1,
|
||||
bindingId: 10,
|
||||
saleUserId: 456,
|
||||
storeUserId: 732,
|
||||
beforeRate: 10,
|
||||
beforeRateLabel: "10%",
|
||||
afterRate: 20,
|
||||
afterRateLabel: "20%",
|
||||
operatorType: "store_user",
|
||||
operatorId: 732,
|
||||
operatorName: "李师傅",
|
||||
remark: "",
|
||||
createdAt: "2026-07-23 11:00:00"
|
||||
)
|
||||
let cell = CommissionRateLogTimelineCell(style: .default, reuseIdentifier: nil)
|
||||
|
||||
cell.configure(with: item, isLatest: true)
|
||||
XCTAssertTrue(cell.accessibilityLabel?.contains("最新记录") == true)
|
||||
XCTAssertFalse(cell.accessibilityLabel?.contains("李师傅") == true)
|
||||
|
||||
cell.configure(with: item, isLatest: false)
|
||||
XCTAssertFalse(cell.accessibilityLabel?.contains("最新记录") == true)
|
||||
}
|
||||
|
||||
func testPageTitleUsesAcquirerNameAndFallsBackWhenEmpty() {
|
||||
let namedController = CommissionRateLogViewController(
|
||||
saleUserId: 456,
|
||||
acquirerName: " 获客员小王 "
|
||||
)
|
||||
namedController.setupNavigationBar()
|
||||
XCTAssertEqual(namedController.title, "给获客员小王的分成记录")
|
||||
|
||||
let fallbackController = CommissionRateLogViewController(
|
||||
saleUserId: 456,
|
||||
acquirerName: " "
|
||||
)
|
||||
fallbackController.setupNavigationBar()
|
||||
XCTAssertEqual(fallbackController.title, "修改记录")
|
||||
}
|
||||
|
||||
func testAcquirerCardUsesUnifiedActionsAndBusinessHierarchy() throws {
|
||||
let cell = CooperationAcquirerCell(style: .default, reuseIdentifier: nil)
|
||||
cell.configure(
|
||||
with: CooperativeSalerEntity(
|
||||
saleUserId: 456,
|
||||
name: "获客员小王",
|
||||
salerPhone: "13800138000",
|
||||
commissionRate: 20,
|
||||
commissionRateLabel: "20%",
|
||||
bindTime: "2026-07-23 11:00:00"
|
||||
)
|
||||
)
|
||||
let fittingSize = cell.contentView.systemLayoutSizeFitting(
|
||||
CGSize(width: 390, height: UIView.layoutFittingCompressedSize.height),
|
||||
withHorizontalFittingPriority: .required,
|
||||
verticalFittingPriority: .fittingSizeLevel
|
||||
)
|
||||
cell.frame = CGRect(x: 0, y: 0, width: 390, height: fittingSize.height)
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
let remarkButton = try XCTUnwrap(
|
||||
subview(in: cell, identifier: "cooperation_acquirer_edit_remark") as? UIButton
|
||||
)
|
||||
let logButton = try XCTUnwrap(
|
||||
subview(in: cell, identifier: "cooperation_acquirer_commission_logs") as? UIButton
|
||||
)
|
||||
let commissionButton = try XCTUnwrap(
|
||||
subview(in: cell, identifier: "cooperation_acquirer_edit_commission") as? UIButton
|
||||
)
|
||||
let bindTimeLabel = try XCTUnwrap(
|
||||
subview(in: cell, identifier: "cooperation_acquirer_bind_time") as? UILabel
|
||||
)
|
||||
|
||||
XCTAssertEqual(remarkButton.bounds.height, AppSpacing.minTouchTarget, accuracy: 0.5)
|
||||
XCTAssertEqual(logButton.bounds.height, remarkButton.bounds.height, accuracy: 0.5)
|
||||
XCTAssertEqual(commissionButton.bounds.height, remarkButton.bounds.height, accuracy: 0.5)
|
||||
XCTAssertEqual(logButton.bounds.width, remarkButton.bounds.width, accuracy: 0.5)
|
||||
XCTAssertEqual(commissionButton.bounds.width, remarkButton.bounds.width, accuracy: 0.5)
|
||||
XCTAssertEqual(bindTimeLabel.text, "绑定于 2026-07-23 11:00")
|
||||
}
|
||||
|
||||
private func envelope(list: [[String: Any]], total: Int, page: Int) -> Data {
|
||||
let object: [String: Any] = [
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": 10,
|
||||
],
|
||||
]
|
||||
return try! JSONSerialization.data(withJSONObject: object)
|
||||
}
|
||||
|
||||
private func record(id: Int, before: Int, after: Int) -> [String: Any] {
|
||||
[
|
||||
"id": id,
|
||||
"binding_id": 10,
|
||||
"sale_user_id": 456,
|
||||
"store_user_id": 732,
|
||||
"before_rate": before,
|
||||
"before_rate_label": "\(before)%",
|
||||
"after_rate": after,
|
||||
"after_rate_label": "\(after)%",
|
||||
"operator_type": "store_user",
|
||||
"operator_id": 732,
|
||||
"operator_name": "李师傅",
|
||||
"remark": "",
|
||||
"created_at": "2026-07-23 11:00:00",
|
||||
]
|
||||
}
|
||||
|
||||
private func subview(in root: UIView, identifier: String) -> UIView? {
|
||||
if root.accessibilityIdentifier == identifier {
|
||||
return root
|
||||
}
|
||||
for child in root.subviews {
|
||||
if let match = subview(in: child, identifier: identifier) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//
|
||||
// CoreLocationProviderTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// Core Location fallback 的授权、定位与逆地理编码测试。
|
||||
@MainActor
|
||||
final class CoreLocationProviderTests: XCTestCase {
|
||||
|
||||
func testPrivacyAgreementIsRequiredBeforeLocation() async {
|
||||
let context = makeContext(privacyAccepted: false)
|
||||
|
||||
await assertLocationError(.privacyNotAccepted) {
|
||||
_ = try await context.provider.requestCoordinate()
|
||||
}
|
||||
XCTAssertEqual(context.manager.requestLocationCallCount, 0)
|
||||
}
|
||||
|
||||
func testDeniedAuthorizationReturnsPermissionError() async {
|
||||
let context = makeContext(authorizationStatus: .denied)
|
||||
|
||||
await assertLocationError(.permissionDenied) {
|
||||
_ = try await context.provider.requestCoordinate()
|
||||
}
|
||||
XCTAssertEqual(context.manager.requestLocationCallCount, 0)
|
||||
}
|
||||
|
||||
func testNotDeterminedAuthorizationContinuesAfterGrant() async throws {
|
||||
let coordinate = CLLocationCoordinate2D(latitude: 30.2741, longitude: 120.1551)
|
||||
let context = makeContext(
|
||||
authorizationStatus: .notDetermined,
|
||||
requestedAuthorizationStatus: .authorizedWhenInUse,
|
||||
locationResult: .success(CLLocation(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude
|
||||
))
|
||||
)
|
||||
|
||||
let result = try await context.provider.requestCoordinate(
|
||||
desiredAccuracy: kCLLocationAccuracyHundredMeters
|
||||
)
|
||||
|
||||
XCTAssertEqual(context.manager.requestAuthorizationCallCount, 1)
|
||||
XCTAssertEqual(context.manager.requestLocationCallCount, 1)
|
||||
XCTAssertEqual(context.manager.desiredAccuracy, kCLLocationAccuracyHundredMeters)
|
||||
XCTAssertEqual(result.latitude, coordinate.latitude, accuracy: 0.000_001)
|
||||
XCTAssertEqual(result.longitude, coordinate.longitude, accuracy: 0.000_001)
|
||||
}
|
||||
|
||||
func testLocationFailureUsesExistingBusinessError() async {
|
||||
let context = makeContext(locationResult: .failure)
|
||||
|
||||
await assertLocationError(.locationFailed) {
|
||||
_ = try await context.provider.requestCoordinate()
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshotContainsReverseGeocodedAddress() async throws {
|
||||
let context = makeContext(
|
||||
locationResult: .success(CLLocation(latitude: 31.2304, longitude: 121.4737)),
|
||||
geocodedAddress: "上海市黄浦区"
|
||||
)
|
||||
|
||||
let snapshot = try await context.provider.requestSnapshot()
|
||||
|
||||
XCTAssertEqual(snapshot.latitude, 31.2304, accuracy: 0.000_001)
|
||||
XCTAssertEqual(snapshot.longitude, 121.4737, accuracy: 0.000_001)
|
||||
XCTAssertEqual(snapshot.address, "上海市黄浦区")
|
||||
XCTAssertEqual(context.geocoder.requestedCoordinates.count, 1)
|
||||
}
|
||||
|
||||
func testSnapshotFallsBackToEmptyAddressWhenGeocoderHasNoResult() async throws {
|
||||
let context = makeContext(
|
||||
locationResult: .success(CLLocation(latitude: 39.9042, longitude: 116.4074)),
|
||||
geocodedAddress: ""
|
||||
)
|
||||
|
||||
let snapshot = try await context.provider.requestSnapshot()
|
||||
|
||||
XCTAssertEqual(snapshot.address, "")
|
||||
}
|
||||
|
||||
private func makeContext(
|
||||
privacyAccepted: Bool = true,
|
||||
authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse,
|
||||
requestedAuthorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse,
|
||||
locationResult: FakeCoreLocationManager.LocationResult = .success(
|
||||
CLLocation(latitude: 39.9042, longitude: 116.4074)
|
||||
),
|
||||
geocodedAddress: String = "北京市东城区"
|
||||
) -> TestContext {
|
||||
let suiteName = "CoreLocationProviderTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
let appStore = AppStore(defaults: defaults)
|
||||
appStore.session.privacyAgreementAccepted = privacyAccepted
|
||||
|
||||
let manager = FakeCoreLocationManager(
|
||||
authorizationStatus: authorizationStatus,
|
||||
requestedAuthorizationStatus: requestedAuthorizationStatus,
|
||||
locationResult: locationResult
|
||||
)
|
||||
let geocoder = FakeLocationAddressGeocoder(address: geocodedAddress)
|
||||
let provider = CoreLocationProvider(
|
||||
appStore: appStore,
|
||||
manager: manager,
|
||||
geocoder: geocoder
|
||||
)
|
||||
return TestContext(provider: provider, manager: manager, geocoder: geocoder)
|
||||
}
|
||||
|
||||
private func assertLocationError(
|
||||
_ expected: LocationProviderError,
|
||||
operation: () async throws -> Void
|
||||
) async {
|
||||
do {
|
||||
try await operation()
|
||||
XCTFail("Expected \(expected)")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? LocationProviderError, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Core Location fallback 测试依赖集合。
|
||||
@MainActor
|
||||
private struct TestContext {
|
||||
let provider: CoreLocationProvider
|
||||
let manager: FakeCoreLocationManager
|
||||
let geocoder: FakeLocationAddressGeocoder
|
||||
}
|
||||
|
||||
/// 可控的 `CLLocationManager` 替身。
|
||||
@MainActor
|
||||
private final class FakeCoreLocationManager: CoreLocationManaging {
|
||||
|
||||
/// 单次定位请求的模拟结果。
|
||||
enum LocationResult {
|
||||
case success(CLLocation)
|
||||
case failure
|
||||
}
|
||||
|
||||
var authorizationStatus: CLAuthorizationStatus
|
||||
var desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
|
||||
weak var delegate: CLLocationManagerDelegate?
|
||||
private(set) var requestAuthorizationCallCount = 0
|
||||
private(set) var requestLocationCallCount = 0
|
||||
|
||||
private let requestedAuthorizationStatus: CLAuthorizationStatus
|
||||
private let locationResult: LocationResult
|
||||
|
||||
init(
|
||||
authorizationStatus: CLAuthorizationStatus,
|
||||
requestedAuthorizationStatus: CLAuthorizationStatus,
|
||||
locationResult: LocationResult
|
||||
) {
|
||||
self.authorizationStatus = authorizationStatus
|
||||
self.requestedAuthorizationStatus = requestedAuthorizationStatus
|
||||
self.locationResult = locationResult
|
||||
}
|
||||
|
||||
func requestWhenInUseAuthorization() {
|
||||
requestAuthorizationCallCount += 1
|
||||
authorizationStatus = requestedAuthorizationStatus
|
||||
delegate?.locationManagerDidChangeAuthorization?(CLLocationManager())
|
||||
}
|
||||
|
||||
func requestLocation() {
|
||||
requestLocationCallCount += 1
|
||||
let callbackManager = CLLocationManager()
|
||||
switch locationResult {
|
||||
case let .success(location):
|
||||
delegate?.locationManager?(callbackManager, didUpdateLocations: [location])
|
||||
case .failure:
|
||||
delegate?.locationManager?(
|
||||
callbackManager,
|
||||
didFailWithError: CLError(.locationUnknown)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 可控的逆地理编码替身。
|
||||
@MainActor
|
||||
private final class FakeLocationAddressGeocoder: LocationAddressGeocoding {
|
||||
private(set) var requestedCoordinates: [CLLocationCoordinate2D] = []
|
||||
private let address: String
|
||||
|
||||
init(address: String) {
|
||||
self.address = address
|
||||
}
|
||||
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
requestedCoordinates.append(CLLocationCoordinate2D(
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
))
|
||||
return address
|
||||
}
|
||||
}
|
||||
@@ -18,20 +18,20 @@ final class HomeMenuIconFactoryTests: XCTestCase {
|
||||
XCTAssertNil(UIImage(systemName: "home_menu_space"))
|
||||
}
|
||||
|
||||
func testSystemSymbolsUseFixedCanvas() {
|
||||
func testSystemSymbolsPreserveSymbolRepresentation() {
|
||||
let iconNames = [
|
||||
"ellipsis",
|
||||
"dot.radiowaves.left.and.right",
|
||||
"rectangle.stack.badge.plus",
|
||||
"mappin.circle.fill",
|
||||
]
|
||||
|
||||
for iconName in iconNames {
|
||||
let image = HomeMenuIconFactory.image(named: iconName)
|
||||
|
||||
XCTAssertNotNil(image)
|
||||
XCTAssertEqual(image?.size, HomeMenuIconFactory.canvasSize)
|
||||
XCTAssertEqual(image?.renderingMode, .alwaysTemplate)
|
||||
XCTAssertFalse(image?.isSymbolImage ?? true)
|
||||
XCTAssertTrue(image?.isSymbolImage ?? false)
|
||||
XCTAssertNotNil(image?.symbolConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ final class HomeMenuIconFactoryTests: XCTestCase {
|
||||
XCTAssertNotNil(HomeMenuIconFactory.image(named: "dot.radiowaves.left.and.right"))
|
||||
XCTAssertNotNil(HomeMenuIconFactory.image(named: "rectangle.stack.badge.plus"))
|
||||
}
|
||||
|
||||
func testLocationReportIconKeepsSystemSymbolLayers() {
|
||||
let image = HomeMenuIconFactory.image(named: "mappin.circle.fill")
|
||||
|
||||
XCTAssertNotNil(image)
|
||||
XCTAssertTrue(image?.isSymbolImage ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页常用应用 cell 的图标布局与复用测试。
|
||||
@@ -50,6 +57,7 @@ final class HomeMenuCellTests: XCTestCase {
|
||||
let menus = [
|
||||
HomeMenuItem(uri: "space", title: "空间设置", iconName: "home_menu_space"),
|
||||
HomeMenuItem(uri: "live", title: "直播管理", iconName: "dot.radiowaves.left.and.right"),
|
||||
HomeMenuItem(uri: "location", title: "位置上报", iconName: "mappin.circle.fill"),
|
||||
HomeMenuItem(uri: "more", title: "更多功能", iconName: "ellipsis"),
|
||||
]
|
||||
|
||||
@@ -60,11 +68,19 @@ final class HomeMenuCellTests: XCTestCase {
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
let iconView = try XCTUnwrap(findImageView(in: cell, identifier: menu.iconName))
|
||||
XCTAssertEqual(iconView.bounds.size, CGSize(width: 24, height: 24))
|
||||
let alignmentSize = iconView.alignmentRect(forFrame: iconView.frame).size
|
||||
XCTAssertEqual(alignmentSize.width, 24, accuracy: 0.001)
|
||||
XCTAssertEqual(alignmentSize.height, 24, accuracy: 0.001)
|
||||
XCTAssertEqual(iconView.superview?.bounds.size, CGSize(width: 40, height: 40))
|
||||
XCTAssertEqual(iconView.image?.size, HomeMenuIconFactory.canvasSize)
|
||||
XCTAssertEqual(iconView.contentMode, .center)
|
||||
XCTAssertNil(iconView.preferredSymbolConfiguration)
|
||||
|
||||
if menu.iconName == "home_menu_space" {
|
||||
XCTAssertEqual(iconView.image?.size, HomeMenuIconFactory.canvasSize)
|
||||
XCTAssertFalse(iconView.image?.isSymbolImage ?? true)
|
||||
} else {
|
||||
XCTAssertTrue(iconView.image?.isSymbolImage ?? false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -200,4 +200,91 @@ final class LoginViewModelTests: XCTestCase {
|
||||
XCTFail("意外错误: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 密码校验成功后发现账号处于注销冷静期,应先确认恢复,再继续原有登录流程。
|
||||
func testPendingAccountDeletionCanBeCanceledAndResumeLogin() async throws {
|
||||
let loginJSON = """
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "pending-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 setUserJSON = """
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "business-token-after-restore",
|
||||
"scenic_users": [],
|
||||
"store_users": []
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let suiteName = "LoginViewModelTests.AccountDeletion.\(UUID().uuidString)"
|
||||
let deletionDefaults = UserDefaults(suiteName: suiteName)!
|
||||
deletionDefaults.removePersistentDomain(forName: suiteName)
|
||||
defer { deletionDefaults.removePersistentDomain(forName: suiteName) }
|
||||
let fixedDate = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let deletionService = AccountDeletionMockService(
|
||||
defaults: deletionDefaults,
|
||||
now: { fixedDate },
|
||||
responseDelayNanoseconds: 0
|
||||
)
|
||||
_ = try await deletionService.submitDeletion(
|
||||
username: "18651857230",
|
||||
verificationCode: AccountDeletionMockService.verificationCode,
|
||||
acknowledgedAssetKinds: Set(AccountDeletionAssetSummary.Kind.allCases),
|
||||
clientRequestID: UUID()
|
||||
)
|
||||
|
||||
let session = MockURLSession(responses: [loginJSON, setUserJSON])
|
||||
let api = AuthAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = LoginViewModel(accountDeletionService: deletionService)
|
||||
viewModel.updateAccount("18651857230")
|
||||
viewModel.updatePassword("secret123")
|
||||
viewModel.togglePrivacyChecked()
|
||||
|
||||
let initialResolution = try await viewModel.login(authAPI: api)
|
||||
guard case let .accountDeletionPending(verifiedResponse, request) = initialResolution else {
|
||||
return XCTFail("应先提示账号正在注销中")
|
||||
}
|
||||
XCTAssertEqual(request.username, "18651857230")
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"])
|
||||
|
||||
let resumedResolution = try await viewModel.resumeLoginAfterCancelingDeletion(
|
||||
verifiedResponse: verifiedResponse,
|
||||
authAPI: api
|
||||
)
|
||||
guard case let .completed(response, account) = resumedResolution else {
|
||||
return XCTFail("取消注销后应完成登录")
|
||||
}
|
||||
XCTAssertEqual(response.token, "business-token-after-restore")
|
||||
XCTAssertEqual(account.businessUserId, 101)
|
||||
XCTAssertEqual(deletionService.loginState(for: "18651857230"), .none)
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/app/v9/login",
|
||||
"/api/app/v9/set-user",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//
|
||||
// OfflineCollectionFeatureTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 线下收款登记、日汇总、失败回滚与幂等补缴流程测试。
|
||||
final class OfflineCollectionFeatureTests: XCTestCase {
|
||||
private var defaults: UserDefaults!
|
||||
private var calendar: Calendar!
|
||||
private var fixedNow: Date!
|
||||
private var service: OfflineCollectionMockService!
|
||||
private let context = OfflineCollectionContext(
|
||||
collectorId: "U10086",
|
||||
collectorName: "张三",
|
||||
storeId: "S1001",
|
||||
storeName: "那拉提旅拍一店",
|
||||
scenicId: "SC001",
|
||||
scenicName: "那拉提景区"
|
||||
)
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let suiteName = "OfflineCollectionFeatureTests"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "Asia/Shanghai")!
|
||||
fixedNow = calendar.date(from: DateComponents(
|
||||
calendar: calendar,
|
||||
timeZone: calendar.timeZone,
|
||||
year: 2026,
|
||||
month: 8,
|
||||
day: 19,
|
||||
hour: 20,
|
||||
minute: 5
|
||||
))!
|
||||
let now = fixedNow!
|
||||
service = OfflineCollectionMockService(
|
||||
defaults: defaults,
|
||||
calendar: calendar,
|
||||
nowProvider: { now },
|
||||
requestDelayNanoseconds: 0
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: "OfflineCollectionFeatureTests")
|
||||
service = nil
|
||||
calendar = nil
|
||||
fixedNow = nil
|
||||
defaults = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testMoneyParserUsesIntegerFenAndEnforcesRange() {
|
||||
XCTAssertEqual(OfflineCollectionMoney.parseFen(".5"), 50)
|
||||
XCTAssertEqual(OfflineCollectionMoney.parseFen("299"), 29_900)
|
||||
XCTAssertEqual(OfflineCollectionMoney.parseFen("99999.99"), 9_999_999)
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen(""))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("0"))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("-1"))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("1.234"))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("100000"))
|
||||
XCTAssertNil(OfflineCollectionMoney.parseFen("1e2"))
|
||||
XCTAssertEqual(OfflineCollectionMoney.display(29_900), "¥299.00")
|
||||
}
|
||||
|
||||
func testDefaultMockDataContainsTodayAndHistoricalOverdueRecords() {
|
||||
let today = service.getDailySummary(date: "2026-08-19", context: context)
|
||||
let overdue = service.getOverdueSummaries(context: context)
|
||||
|
||||
XCTAssertEqual(today.totalCount, 3)
|
||||
XCTAssertEqual(today.totalAmountFen, 89_700)
|
||||
XCTAssertEqual(today.pendingAmountFen, 89_700)
|
||||
XCTAssertEqual(overdue.count, 1)
|
||||
XCTAssertEqual(overdue.first?.businessDate, "2026-08-18")
|
||||
XCTAssertEqual(overdue.first?.pendingCount, 2)
|
||||
XCTAssertEqual(overdue.first?.pendingAmountFen, 50_000)
|
||||
XCTAssertEqual(overdue.first?.hasOverdue, true)
|
||||
}
|
||||
|
||||
func testCreateCollectionUpdatesSummaryAndPersists() async throws {
|
||||
let receipt = try await service.createOfflineCollection(
|
||||
amountFen: 29_900,
|
||||
paymentMethod: .wechat,
|
||||
context: context
|
||||
)
|
||||
|
||||
XCTAssertEqual(receipt.record.status, .pending)
|
||||
XCTAssertEqual(receipt.summary.totalCount, 4)
|
||||
XCTAssertEqual(receipt.summary.totalAmountFen, 119_600)
|
||||
XCTAssertEqual(receipt.summary.pendingAmountFen, 119_600)
|
||||
|
||||
let now = fixedNow!
|
||||
let reloaded = OfflineCollectionMockService(
|
||||
defaults: defaults,
|
||||
calendar: calendar,
|
||||
nowProvider: { now },
|
||||
requestDelayNanoseconds: 0
|
||||
)
|
||||
XCTAssertEqual(reloaded.getDailySummary(date: "2026-08-19", context: context), receipt.summary)
|
||||
}
|
||||
|
||||
func testSuccessfulSettlementGeneratesIndependentBatchAndSettlesSnapshot() async throws {
|
||||
let records = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: "2026-08-19",
|
||||
recordIds: records.map(\.id),
|
||||
amountFen: records.reduce(0) { $0 + $1.amountFen },
|
||||
clientRequestId: "LOCAL-SUCCESS-001"
|
||||
)
|
||||
|
||||
let batch = try await service.settleDailyCollections(request: request, context: context)
|
||||
let summary = service.getDailySummary(date: "2026-08-19", context: context)
|
||||
let settledRecords = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
|
||||
XCTAssertEqual(batch.amountFen, 89_700)
|
||||
XCTAssertEqual(batch.recordCount, 3)
|
||||
XCTAssertEqual(batch.transactionType, "OFFLINE_COLLECTION_SETTLEMENT")
|
||||
XCTAssertEqual(summary.totalAmountFen, summary.settledAmountFen + summary.pendingAmountFen)
|
||||
XCTAssertEqual(summary.pendingAmountFen, 0)
|
||||
XCTAssertTrue(settledRecords.allSatisfy { $0.status == .settled })
|
||||
XCTAssertTrue(settledRecords.allSatisfy { $0.settlementBatchId == batch.id })
|
||||
}
|
||||
|
||||
func testFailedSettlementDoesNotCreateBatchOrChangeRecords() async throws {
|
||||
let before = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: "2026-08-19",
|
||||
recordIds: before.map(\.id),
|
||||
amountFen: before.reduce(0) { $0 + $1.amountFen },
|
||||
clientRequestId: "LOCAL-FAILURE-001"
|
||||
)
|
||||
service.simulateNextSettlementFailure()
|
||||
|
||||
do {
|
||||
_ = try await service.settleDailyCollections(request: request, context: context)
|
||||
XCTFail("应返回 Mock 补缴失败")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? OfflineCollectionError, .settlementFailed)
|
||||
}
|
||||
|
||||
XCTAssertEqual(service.getDailyRecords(date: "2026-08-19", context: context), before)
|
||||
XCTAssertTrue(service.getSettlementBatches(date: "2026-08-19", context: context).isEmpty)
|
||||
XCTAssertEqual(service.getDailySummary(date: "2026-08-19", context: context).pendingAmountFen, 89_700)
|
||||
}
|
||||
|
||||
func testSettlementOnlySettlesFrozenRecordSnapshot() async throws {
|
||||
let frozenRecords = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: "2026-08-19",
|
||||
recordIds: frozenRecords.map(\.id),
|
||||
amountFen: frozenRecords.reduce(0) { $0 + $1.amountFen },
|
||||
clientRequestId: "LOCAL-SNAPSHOT-001"
|
||||
)
|
||||
let newReceipt = try await service.createOfflineCollection(
|
||||
amountFen: 12_300,
|
||||
paymentMethod: .cash,
|
||||
context: context
|
||||
)
|
||||
|
||||
_ = try await service.settleDailyCollections(request: request, context: context)
|
||||
|
||||
let records = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let newRecord = try XCTUnwrap(records.first(where: { $0.id == newReceipt.record.id }))
|
||||
XCTAssertEqual(newRecord.status, .pending)
|
||||
XCTAssertEqual(service.getDailySummary(date: "2026-08-19", context: context).pendingAmountFen, 12_300)
|
||||
}
|
||||
|
||||
func testSameClientRequestIdDoesNotCreateDuplicateBatch() async throws {
|
||||
let records = service.getDailyRecords(date: "2026-08-19", context: context)
|
||||
let request = OfflineSettlementRequest(
|
||||
businessDate: "2026-08-19",
|
||||
recordIds: records.map(\.id),
|
||||
amountFen: records.reduce(0) { $0 + $1.amountFen },
|
||||
clientRequestId: "LOCAL-IDEMPOTENT-001"
|
||||
)
|
||||
|
||||
let first = try await service.settleDailyCollections(request: request, context: context)
|
||||
let second = try await service.settleDailyCollections(request: request, context: context)
|
||||
|
||||
XCTAssertEqual(first, second)
|
||||
XCTAssertEqual(service.getSettlementBatches(date: "2026-08-19", context: context).count, 1)
|
||||
}
|
||||
|
||||
func testRegistrationViewModelValidatesAndSubmitsForm() async {
|
||||
let viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service)
|
||||
var successReceipt: OfflineCollectionRegistrationReceipt?
|
||||
viewModel.onRegistrationSuccess = { successReceipt = $0 }
|
||||
|
||||
XCTAssertEqual(viewModel.paymentMethod, .wechat)
|
||||
viewModel.updateAmount("299")
|
||||
XCTAssertTrue(viewModel.canSubmit)
|
||||
|
||||
await viewModel.submit()
|
||||
|
||||
XCTAssertEqual(successReceipt?.record.amountFen, 29_900)
|
||||
XCTAssertEqual(successReceipt?.record.paymentMethod, .wechat)
|
||||
XCTAssertFalse(viewModel.isSubmitting)
|
||||
XCTAssertEqual(successReceipt?.summary.totalCount, 4)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
||||
XCTAssertFalse(viewModel.showAmountDialog)
|
||||
}
|
||||
|
||||
func testPayCodeLoadingStateCoversInitialRenderAndRequest() async {
|
||||
let payCodeJSON = """
|
||||
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
|
||||
""".data(using: .utf8)!
|
||||
let session = MockURLSession(responses: [payCodeJSON])
|
||||
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||
var loadingStates = [viewModel.isPayCodeLoading]
|
||||
viewModel.onStateChange = {
|
||||
loadingStates.append(viewModel.isPayCodeLoading)
|
||||
}
|
||||
|
||||
await viewModel.loadPayCode(api: api)
|
||||
|
||||
XCTAssertEqual(loadingStates.first, true)
|
||||
XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 })
|
||||
XCTAssertEqual(loadingStates.last, false)
|
||||
XCTAssertTrue(viewModel.hasPayCode)
|
||||
}
|
||||
|
||||
func testToggleReceiveVoicePersistsInAppStore() {
|
||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||
viewModel.refreshLocalState()
|
||||
@@ -79,8 +99,9 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
|
||||
}
|
||||
|
||||
func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() {
|
||||
appStore.session.userName = "摄影师小王"
|
||||
func testNalatiAccountInfoUsesPhotographerRealNameAndAllPermissionStores() {
|
||||
appStore.session.userName = "摄影师昵称"
|
||||
appStore.session.realName = "摄影师真实姓名"
|
||||
appStore.permissions.saveRolePermissionList([
|
||||
RolePermissionResponse(
|
||||
role: RoleInfo(id: 41, name: "摄影师", roleCode: "photographer"),
|
||||
@@ -93,10 +114,19 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
||||
|
||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||
|
||||
XCTAssertEqual(viewModel.displayPhotographerName, "摄影师小王")
|
||||
XCTAssertEqual(viewModel.displayPhotographerName, "摄影师真实姓名")
|
||||
XCTAssertEqual(viewModel.displayStoreNames, "空中草原店、河谷草原店")
|
||||
}
|
||||
|
||||
func testPhotographerNameDoesNotFallBackToNicknameWhenRealNameMissing() {
|
||||
appStore.session.userName = "摄影师昵称"
|
||||
appStore.session.realName = " "
|
||||
|
||||
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
|
||||
|
||||
XCTAssertEqual(viewModel.displayPhotographerName, "-")
|
||||
}
|
||||
|
||||
func testLoadPayCodeClearsQRWhenScenicMissing() async {
|
||||
appStore.session.currentScenicId = 0
|
||||
let session = MockURLSession(responses: [])
|
||||
@@ -105,6 +135,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
|
||||
|
||||
await viewModel.loadPayCode(api: api)
|
||||
|
||||
XCTAssertFalse(viewModel.isPayCodeLoading)
|
||||
XCTAssertFalse(viewModel.hasPayCode)
|
||||
XCTAssertEqual(session.requests.count, 0)
|
||||
}
|
||||
|
||||
@@ -55,11 +55,12 @@ final class PaymentModelsTests: XCTestCase {
|
||||
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
|
||||
}
|
||||
|
||||
func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() {
|
||||
func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
|
||||
XCTAssertEqual(
|
||||
PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"),
|
||||
"当前摄影师"
|
||||
PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
|
||||
"摄影师真实姓名"
|
||||
)
|
||||
XCTAssertEqual(PaymentAccountDisplayFormatter.photographerName(realName: " "), "-")
|
||||
XCTAssertEqual(
|
||||
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
|
||||
"一号店、二号店"
|
||||
|
||||
@@ -26,6 +26,22 @@ final class ProfileViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
|
||||
}
|
||||
|
||||
func testColdStartBasicInfoUsesPersistedSessionCache() {
|
||||
AppStore.shared.session.userName = "缓存昵称"
|
||||
AppStore.shared.session.realName = "缓存真实姓名"
|
||||
AppStore.shared.session.phone = "186****7230"
|
||||
AppStore.shared.session.avatar = "https://cdn.example.com/cached-avatar.jpg"
|
||||
|
||||
let viewModel = ProfileViewModel()
|
||||
|
||||
XCTAssertTrue(viewModel.hasCachedBasicInfo)
|
||||
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
|
||||
XCTAssertEqual(viewModel.displayRealName, "缓存真实姓名")
|
||||
XCTAssertEqual(viewModel.displayPhone, "186****7230")
|
||||
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/cached-avatar.jpg")
|
||||
XCTAssertEqual(viewModel.displayUID, "profile-user")
|
||||
}
|
||||
|
||||
func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() {
|
||||
AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue
|
||||
let viewModel = ProfileViewModel()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// SessionExpiredDialogViewControllerTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 登录失效弹窗测试,覆盖强制展示配置、Android 对齐文案与确认防重。
|
||||
@MainActor
|
||||
final class SessionExpiredDialogViewControllerTests: XCTestCase {
|
||||
|
||||
func testDialogUsesForcedPresentationAndAndroidCopy() throws {
|
||||
let dialog = SessionExpiredDialogViewController(onConfirm: {})
|
||||
dialog.loadViewIfNeeded()
|
||||
|
||||
XCTAssertEqual(dialog.modalPresentationStyle, .overFullScreen)
|
||||
XCTAssertEqual(dialog.modalTransitionStyle, .crossDissolve)
|
||||
XCTAssertTrue(dialog.isModalInPresentation)
|
||||
XCTAssertTrue(dialog.view.accessibilityViewIsModal)
|
||||
|
||||
let labels = dialog.view.allSubviews(of: UILabel.self)
|
||||
XCTAssertTrue(labels.contains { $0.text == "登录提醒" })
|
||||
XCTAssertTrue(labels.contains { $0.text == "当前用户已在其他设备登录,\n请重新登录" })
|
||||
|
||||
let button = try XCTUnwrap(
|
||||
dialog.view.allSubviews(of: UIButton.self).first {
|
||||
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
|
||||
}
|
||||
)
|
||||
XCTAssertEqual(button.title(for: .normal), "确认并退出登录")
|
||||
}
|
||||
|
||||
func testConfirmCallbackOnlyRunsOnce() throws {
|
||||
var confirmationCount = 0
|
||||
let dialog = SessionExpiredDialogViewController {
|
||||
confirmationCount += 1
|
||||
}
|
||||
dialog.loadViewIfNeeded()
|
||||
let button = try XCTUnwrap(
|
||||
dialog.view.allSubviews(of: UIButton.self).first {
|
||||
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
|
||||
}
|
||||
)
|
||||
|
||||
button.sendActions(for: .touchUpInside)
|
||||
button.sendActions(for: .touchUpInside)
|
||||
|
||||
XCTAssertEqual(confirmationCount, 1)
|
||||
XCTAssertFalse(button.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIView {
|
||||
func allSubviews<View: UIView>(of type: View.Type) -> [View] {
|
||||
subviews.flatMap { subview -> [View] in
|
||||
let current = (subview as? View).map { [$0] } ?? []
|
||||
return current + subview.allSubviews(of: type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,12 +60,44 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
|
||||
XCTAssertEqual(store.absoluteURL(for: loaded.first?.localPath ?? "", albumId: 33), file)
|
||||
}
|
||||
|
||||
func testLoadNormalizesInterruptedRetouchToUploadedFailure() throws {
|
||||
let root = try makeTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = makeStore(root: root)
|
||||
let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33)
|
||||
try Data([1, 2, 3]).write(to: file)
|
||||
let record = TravelAlbumOTGPhotoRecord(
|
||||
id: "retouching",
|
||||
fileName: "A.JPG",
|
||||
localPath: file.path,
|
||||
capturedAt: "2026-07-07 10:00:00",
|
||||
fileSizeBytes: 3,
|
||||
status: .uploading,
|
||||
progress: 100,
|
||||
albumId: 33,
|
||||
userId: "u1",
|
||||
remoteUrl: "https://cdn/a.jpg",
|
||||
materialId: 10,
|
||||
autoRetouchState: .processing,
|
||||
autoRetouchTemplateId: "brocade"
|
||||
)
|
||||
|
||||
store.save([record], albumId: 33)
|
||||
let loaded = try XCTUnwrap(store.load(albumId: 33).first)
|
||||
|
||||
XCTAssertEqual(loaded.status, .uploaded)
|
||||
XCTAssertEqual(loaded.progress, 100)
|
||||
XCTAssertEqual(loaded.autoRetouchState, .failed)
|
||||
XCTAssertEqual(loaded.autoRetouchTemplateId, "brocade")
|
||||
}
|
||||
|
||||
func testRemoveAndClearAlbumDeleteFiles() throws {
|
||||
let root = try makeTempDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = makeStore(root: root)
|
||||
let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33)
|
||||
try Data([1]).write(to: file)
|
||||
let retouchedFile = try store.writeRetouchedImage(Data([2]), filename: "A.JPG", albumId: 33)
|
||||
let record = TravelAlbumOTGPhotoRecord(
|
||||
id: "1",
|
||||
fileName: "A.JPG",
|
||||
@@ -74,13 +106,17 @@ final class TravelAlbumOTGStorageTests: XCTestCase {
|
||||
fileSizeBytes: 1,
|
||||
status: .pending,
|
||||
albumId: 33,
|
||||
userId: "u1"
|
||||
userId: "u1",
|
||||
autoRetouchState: .completed,
|
||||
autoRetouchTemplateId: "brocade",
|
||||
retouchedPath: retouchedFile.path
|
||||
)
|
||||
store.save([record], albumId: 33)
|
||||
|
||||
store.remove(albumId: 33, photoId: "1")
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: file.path))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: retouchedFile.path))
|
||||
XCTAssertTrue(store.load(albumId: 33).isEmpty)
|
||||
|
||||
let another = try store.uniqueOriginalFileURL(filename: "B.JPG", albumId: 33)
|
||||
|
||||
@@ -59,6 +59,55 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||
XCTAssertFalse(viewModel.isCreateSheetVisible)
|
||||
}
|
||||
|
||||
func testCreateSuccessPersistsSelectedAutoRetouchTemplate() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.createResponse = TravelAlbumCreateResponse(id: 19)
|
||||
let defaults = makeOTGTestDefaults()
|
||||
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
|
||||
let viewModel = TravelAlbumEntryViewModel(
|
||||
currentScenicIdProvider: { 1 },
|
||||
autoRetouchConfigurationStore: configurationStore
|
||||
)
|
||||
|
||||
await viewModel.confirmCreate(
|
||||
mode: .preShoot,
|
||||
freeCount: "2",
|
||||
singlePrice: "12.5",
|
||||
packagePrice: "88",
|
||||
autoRetouchConfiguration: .enabled(templateID: "brocade"),
|
||||
order: nil,
|
||||
api: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
configurationStore.configuration(albumID: 19),
|
||||
.enabled(templateID: "brocade")
|
||||
)
|
||||
}
|
||||
|
||||
func testCreateRejectsEnabledAutoRetouchWithoutValidTemplate() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||
var message = ""
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
await viewModel.confirmCreate(
|
||||
mode: .preShoot,
|
||||
freeCount: "",
|
||||
singlePrice: "12.5",
|
||||
packagePrice: "",
|
||||
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
|
||||
isEnabled: true,
|
||||
templateID: "missing-template"
|
||||
),
|
||||
order: nil,
|
||||
api: api
|
||||
)
|
||||
|
||||
XCTAssertEqual(message, "请选择修图模板")
|
||||
XCTAssertTrue(api.createRequests.isEmpty)
|
||||
}
|
||||
|
||||
func testOrderAlreadyBoundReloadsOrders() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.createError = APIError.serverCode(100001, "订单已关联")
|
||||
@@ -73,6 +122,31 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 自动修图选择弹窗的界面状态测试。
|
||||
final class TravelAlbumAutoRetouchSettingSheetTests: XCTestCase {
|
||||
func testSelectingTemplateImmediatelyRefreshesSelectedAppearance() throws {
|
||||
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
|
||||
configuration: .disabled,
|
||||
startsWithModeSelection: false
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 402, height: 874)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let tableView = try XCTUnwrap(
|
||||
controller.view.subviews.compactMap { $0 as? UITableView }.first
|
||||
)
|
||||
let selectedIndexPath = IndexPath(row: 1, section: 0)
|
||||
|
||||
tableView.delegate?.tableView?(tableView, didSelectRowAt: selectedIndexPath)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
XCTAssertEqual(tableView.cellForRow(at: selectedIndexPath)?.accessibilityValue, "已选择")
|
||||
XCTAssertEqual(tableView.cellForRow(at: IndexPath(row: 0, section: 0))?.accessibilityValue, "未选择")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册详情 ViewModel 测试。
|
||||
final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
@@ -785,6 +859,134 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(records.allSatisfy { UUID(uuidString: $0.clientPhotoId) != nil })
|
||||
}
|
||||
|
||||
func testAutoRetouchConfigurationLoadsAndUpdatesUploadChip() {
|
||||
let context = makeOTGTestContext()
|
||||
let defaults = makeOTGTestDefaults()
|
||||
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
|
||||
configurationStore.save(.enabled(templateID: "brocade"), albumID: 9)
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
userDefaults: defaults,
|
||||
autoRetouchConfigurationStore: configurationStore
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.retouchOption, "AI 修图")
|
||||
|
||||
viewModel.updateAutoRetouchConfiguration(.disabled)
|
||||
|
||||
XCTAssertEqual(viewModel.retouchOption, "不修图")
|
||||
XCTAssertEqual(configurationStore.configuration(albumID: 9), .disabled)
|
||||
}
|
||||
|
||||
func testAutoRetouchFirstLevelOnlyContainsNoRetouchAndAIRetouch() {
|
||||
XCTAssertEqual(TravelAlbumRetouchMode.allCases, [.disabled, .aiRetouch])
|
||||
XCTAssertEqual(TravelAlbumRetouchMode.disabled.title, "不修图")
|
||||
XCTAssertEqual(TravelAlbumRetouchMode.aiRetouch.title, "AI 修图")
|
||||
}
|
||||
|
||||
func testUploadAutomaticallyRetouchesAndPersistsEditedResult() async throws {
|
||||
let context = makeOTGTestContext()
|
||||
defer { try? FileManager.default.removeItem(at: context.root) }
|
||||
let defaults = makeOTGTestDefaults()
|
||||
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
|
||||
configurationStore.save(.enabled(templateID: "brocade"), albumID: 9)
|
||||
let materialID = 990_901
|
||||
let uploader = MockTravelAlbumOTGUploader(materialID: materialID)
|
||||
let processor = MockTravelAlbumAutoRetouchProcessor()
|
||||
let resultStore = TravelAlbumAIEditResultStore.shared
|
||||
resultStore.remove(materialID: materialID)
|
||||
defer { resultStore.remove(materialID: materialID) }
|
||||
let record = try makePersistedOTGRecord(
|
||||
context: context,
|
||||
id: "auto-retouch",
|
||||
capturedAt: "2026-07-08 12:05:00",
|
||||
status: .pending
|
||||
)
|
||||
context.store.save([record], albumId: 9)
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
uploader: uploader,
|
||||
userDefaults: defaults,
|
||||
autoRetouchConfigurationStore: configurationStore,
|
||||
autoRetouchProcessor: processor
|
||||
)
|
||||
|
||||
viewModel.reloadLocalPhotos()
|
||||
viewModel.onSpecifyUploadOptionSelected(.allPending)
|
||||
await waitUntil(timeout: 2) {
|
||||
context.store.load(albumId: 9).first?.autoRetouchState == .completed
|
||||
}
|
||||
|
||||
let saved = try XCTUnwrap(context.store.load(albumId: 9).first)
|
||||
XCTAssertEqual(saved.status, .uploaded)
|
||||
XCTAssertEqual(saved.materialId, materialID)
|
||||
XCTAssertEqual(saved.autoRetouchState, .completed)
|
||||
XCTAssertEqual(saved.autoRetouchTemplateId, "brocade")
|
||||
XCTAssertTrue(context.store.fileExists(relativePath: saved.retouchedPath, albumId: 9))
|
||||
XCTAssertEqual(processor.processedPresetIDs, ["brocade"])
|
||||
XCTAssertEqual(resultStore.state(for: materialID).appliedPresetID, "brocade")
|
||||
XCTAssertEqual(viewModel.photos.first?.autoRetouchState, .completed)
|
||||
|
||||
viewModel.deletePhoto(photoId: saved.id)
|
||||
|
||||
XCTAssertFalse(context.store.fileExists(relativePath: saved.retouchedPath, albumId: 9))
|
||||
XCTAssertFalse(resultStore.state(for: materialID).hasEditedResult)
|
||||
}
|
||||
|
||||
func testAutoRetouchFailureKeepsUploadSuccessfulAndCanRetryWithoutReupload() async throws {
|
||||
let context = makeOTGTestContext()
|
||||
defer { try? FileManager.default.removeItem(at: context.root) }
|
||||
let defaults = makeOTGTestDefaults()
|
||||
let configurationStore = TravelAlbumAutoRetouchConfigurationStore(userDefaults: defaults)
|
||||
configurationStore.save(.enabled(templateID: "mist"), albumID: 9)
|
||||
let materialID = 990_902
|
||||
let resultStore = TravelAlbumAIEditResultStore.shared
|
||||
resultStore.remove(materialID: materialID)
|
||||
defer { resultStore.remove(materialID: materialID) }
|
||||
let uploader = MockTravelAlbumOTGUploader(materialID: materialID)
|
||||
let processor = MockTravelAlbumAutoRetouchProcessor()
|
||||
processor.shouldFail = true
|
||||
let record = try makePersistedOTGRecord(
|
||||
context: context,
|
||||
id: "retry-retouch",
|
||||
capturedAt: "2026-07-08 12:05:00",
|
||||
status: .pending
|
||||
)
|
||||
context.store.save([record], albumId: 9)
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
uploader: uploader,
|
||||
userDefaults: defaults,
|
||||
autoRetouchConfigurationStore: configurationStore,
|
||||
autoRetouchProcessor: processor
|
||||
)
|
||||
|
||||
viewModel.reloadLocalPhotos()
|
||||
viewModel.onSpecifyUploadOptionSelected(.allPending)
|
||||
await waitUntil(timeout: 2) {
|
||||
context.store.load(albumId: 9).first?.autoRetouchState == .failed
|
||||
}
|
||||
|
||||
var saved = try XCTUnwrap(context.store.load(albumId: 9).first)
|
||||
XCTAssertEqual(saved.status, .uploaded)
|
||||
XCTAssertEqual(viewModel.tabCounts[TravelAlbumOTGTransferTab.failed.rawValue], 0)
|
||||
XCTAssertEqual(uploader.uploadCallCount, 1)
|
||||
|
||||
processor.shouldFail = false
|
||||
viewModel.retryAutoRetouch(photoId: saved.id)
|
||||
await waitUntil(timeout: 2) {
|
||||
context.store.load(albumId: 9).first?.autoRetouchState == .completed
|
||||
}
|
||||
|
||||
saved = try XCTUnwrap(context.store.load(albumId: 9).first)
|
||||
XCTAssertEqual(saved.autoRetouchState, .completed)
|
||||
XCTAssertEqual(uploader.uploadCallCount, 1)
|
||||
XCTAssertEqual(processor.processedPresetIDs, ["mist", "mist"])
|
||||
}
|
||||
|
||||
func testStartSynchronizesPersistedUploadStatusesFromServerClientPhotoIds() async {
|
||||
let context = makeOTGTestContext()
|
||||
let api = TravelAlbumMockAPI()
|
||||
@@ -1013,9 +1215,13 @@ private func makeWiredViewModel(
|
||||
manager: MockWiredCameraConnectionManager,
|
||||
uploader: MockTravelAlbumOTGUploader? = nil,
|
||||
api: TravelAlbumMockAPI? = nil,
|
||||
userDefaults: UserDefaults = makeOTGTestDefaults()
|
||||
userDefaults: UserDefaults = makeOTGTestDefaults(),
|
||||
autoRetouchConfigurationStore: (any TravelAlbumAutoRetouchConfigurationStoring)? = nil,
|
||||
autoRetouchProcessor: (any TravelAlbumAutoRetouchProcessing)? = nil
|
||||
) -> WiredCameraTransferViewModel {
|
||||
prepareAppStoreForOTGTests()
|
||||
let configurationStore = autoRetouchConfigurationStore
|
||||
?? TravelAlbumAutoRetouchConfigurationStore(userDefaults: userDefaults)
|
||||
return WiredCameraTransferViewModel(
|
||||
albumId: albumId,
|
||||
albumTitle: "相册",
|
||||
@@ -1024,7 +1230,9 @@ private func makeWiredViewModel(
|
||||
storage: context.store,
|
||||
uploader: uploader ?? MockTravelAlbumOTGUploader(),
|
||||
api: api ?? TravelAlbumMockAPI(),
|
||||
userDefaults: userDefaults
|
||||
userDefaults: userDefaults,
|
||||
autoRetouchConfigurationStore: configurationStore,
|
||||
autoRetouchProcessor: autoRetouchProcessor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1054,7 +1262,9 @@ private func makeOTGPhotoItem(
|
||||
progress: 0,
|
||||
errorMessage: nil,
|
||||
localPath: "originals/\(id).JPG",
|
||||
remoteUrl: ""
|
||||
remoteUrl: "",
|
||||
autoRetouchState: .none,
|
||||
autoRetouchTemplateId: nil
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1225,6 +1435,11 @@ private final class MockCameraDriver: CameraDriver {
|
||||
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||
private(set) var uploadCallCount = 0
|
||||
private(set) var uploadedRecordIds: [String] = []
|
||||
private let materialID: Int
|
||||
|
||||
init(materialID: Int = 1) {
|
||||
self.materialID = materialID
|
||||
}
|
||||
|
||||
func upload(
|
||||
record: TravelAlbumOTGPhotoRecord,
|
||||
@@ -1233,7 +1448,22 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||
) async throws -> TravelAlbumMaterial {
|
||||
uploadCallCount += 1
|
||||
uploadedRecordIds.append(record.id)
|
||||
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
|
||||
return TravelAlbumMaterial(id: materialID, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动修图测试替身,可切换失败以验证重试闭环。
|
||||
@MainActor
|
||||
private final class MockTravelAlbumAutoRetouchProcessor: TravelAlbumAutoRetouchProcessing {
|
||||
var shouldFail = false
|
||||
private(set) var processedPresetIDs: [String] = []
|
||||
|
||||
func process(sourceURL: URL, preset: TravelAlbumEditPreset) async throws -> Data {
|
||||
processedPresetIDs.append(preset.id)
|
||||
if shouldFail {
|
||||
throw TravelAlbumAutoRetouchError.resultEncodingFailed
|
||||
}
|
||||
return Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6rE8AAAAASUVORK5CYII=")!
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user