16 Commits
Author SHA1 Message Date
han xin e1175066b9 删除没用的卡片 2026-08-24 11:05:51 +08:00
han xin 5cf4409bad 新增线下收款记录和 ai 修图优化 2026-08-21 15:51:52 +08:00
lujiuyin 6fe9928b49 Merge branch 'dev_1_2_1' into xh_test 2026-08-03 10:48:41 +08:00
XHYourName 6129a1632f 新增ai修图 2026-07-31 16:48:28 +08:00
lujiuyin 1cdce616e4 chore: 更新 iOS 版本号至 1.2.1 2026-07-31 15:25:59 +08:00
lujiuyin 212607fc82 fix: 增加登录失效确认弹窗 2026-07-31 15:25:12 +08:00
lujiuyin 0cf0a41a60 fix: 修正账号姓名展示与资料缓存 2026-07-31 15:19:22 +08:00
lujiuyinandCursor f863004c8d fix: 优化收款码加载态,避免首屏错误占位闪现
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 14:07:05 +08:00
lujiuyinandCursor 61c56c7adb chore: bump 版本至 1.1.5 (1010501)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:47:44 +08:00
lujiuyin 413a9603a4 fix: 修复首页位置上报图标渲染 2026-07-29 15:14:05 +08:00
lujiuyin f87b13cc05 feat: 支持模拟器定位与地图 fallback 2026-07-24 10:04:55 +08:00
lujiuyin 0e0415eec4 feat: 添加获客员分成记录并重构列表 2026-07-23 17:43:13 +08:00
lujiuyin 7ccbc5568d fix: 优化旅拍相册入口与二维码提示布局 2026-07-23 15:40:51 +08:00
lujiuyin 787a166263 feat: 品牌化那拉提景区收款页 2026-07-23 15:39:34 +08:00
lujiuyinandCursor 74f7370aab docs: 补充 type=13 新获客线索推送样本与字段说明。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:56:40 +08:00
lujiuyinandCursor 1b2637f0ee fix: 推送点击仅按 type 路由到收款页或消息中心。
对齐后端消息类型约定,去掉 route/uri 等兼容字段,并补充联调日志与单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:20:36 +08:00
136 changed files with 15951 additions and 2574 deletions
+13 -3
View File
@@ -1,10 +1,17 @@
platform :ios, '16.0' platform :ios, '16.0'
project 'suixinkan.xcodeproj',
'Debug' => :debug,
'SimulatorDebug' => :debug,
'Release' => :release
target 'suixinkan' do target 'suixinkan' do
use_frameworks! use_frameworks!
pod 'AMapLocation-NO-IDFA' # 高德 SDK 未提供 arm64-simulator slice,仅在真机配置中接入。
pod 'AMap3DMap-NO-IDFA' pod 'AMapFoundation-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'AMapSearch-NO-IDFA' pod 'AMapLocation-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'AMap3DMap-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'AMapSearch-NO-IDFA', :configurations => ['Debug', 'Release']
pod 'UMCommon' pod 'UMCommon'
pod 'UMDevice' pod 'UMDevice'
pod 'UMAPM' pod 'UMAPM'
@@ -24,6 +31,9 @@ post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
target.build_configurations.each do |config| target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' 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 end
end end
+2 -1
View File
@@ -58,6 +58,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- AMap3DMap-NO-IDFA - AMap3DMap-NO-IDFA
- AMapFoundation-NO-IDFA
- AMapLocation-NO-IDFA - AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA - AMapSearch-NO-IDFA
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
@@ -109,6 +110,6 @@ SPEC CHECKSUMS:
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19 UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949 WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412 PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
+2 -1
View File
@@ -58,6 +58,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- AMap3DMap-NO-IDFA - AMap3DMap-NO-IDFA
- AMapFoundation-NO-IDFA
- AMapLocation-NO-IDFA - AMapLocation-NO-IDFA
- AMapSearch-NO-IDFA - AMapSearch-NO-IDFA
- IQKeyboardManagerSwift - IQKeyboardManagerSwift
@@ -109,6 +110,6 @@ SPEC CHECKSUMS:
UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19 UMDevice: 20b7b3c37a36b2c5c7ca8b5a54386ff9ed2b3b19
WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949 WechatOpenSDK-XCFramework: ff342ae616bb86df3d236aca38059dfd4bc4a949
PODFILE CHECKSUM: e7971a58b5d46ffc03f36ef716b922b6a75a6412 PODFILE CHECKSUM: 5abf70ade4a9c4b66b64877fdea91f1d086bcaf5
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
+2932 -2135
View File
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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}/IQTextInputViewNotification/IQTextInputViewNotification.framework"
install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework" install_framework "${BUILT_PRODUCTS_DIR}/IQTextView/IQTextView.framework"
fi 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 if [ "${COCOAPODS_PARALLEL_CODE_SIGN}" == "true" ]; then
wait wait
fi fi
@@ -0,0 +1 @@
${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh
@@ -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
@@ -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
+45
View File
@@ -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
@@ -0,0 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.action { fill: #F6F8FB; stroke: #D5DEE9; stroke-width: 2; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="430" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">照片预览与左右切换</text>
<rect x="42" y="92" width="205" height="104" rx="16" class="box"/>
<text x="144" y="126" text-anchor="middle" class="label">相册管理缩略图</text>
<text x="144" y="158" text-anchor="middle" class="small">点击任意照片</text>
<path d="M247 144 H326" class="line"/>
<rect x="326" y="78" width="300" height="132" rx="18" class="active"/>
<text x="476" y="115" text-anchor="middle" class="label">单一返回按钮 + 文件信息</text>
<text x="476" y="146" text-anchor="middle" class="small">文件名 / 文件大小 / 当前照片</text>
<text x="476" y="176" text-anchor="middle" class="small">底部:AI修图、删除、刷新</text>
<path d="M626 144 H706" class="line"/>
<rect x="706" y="78" width="205" height="132" rx="18" class="box"/>
<text x="808" y="115" text-anchor="middle" class="label">左右滑动</text>
<text x="808" y="146" text-anchor="middle" class="small">切换上一张 / 下一张</text>
<text x="808" y="176" text-anchor="middle" class="small">首尾不循环</text>
<path d="M911 144 H990" class="line"/>
<rect x="990" y="92" width="168" height="104" rx="16" class="box"/>
<text x="1074" y="126" text-anchor="middle" class="label">同步刷新信息</text>
<text x="1074" y="158" text-anchor="middle" class="small">名称、大小、结果状态</text>
<rect x="42" y="258" width="344" height="112" rx="16" class="action"/>
<text x="214" y="292" text-anchor="middle" class="label">删除当前照片</text>
<text x="214" y="323" text-anchor="middle" class="small">删除后定位到相邻照片</text>
<text x="214" y="349" text-anchor="middle" class="small">最后一张删除后返回相册管理</text>
<rect x="428" y="258" width="344" height="112" rx="16" class="action"/>
<text x="600" y="292" text-anchor="middle" class="label">刷新当前照片</text>
<text x="600" y="323" text-anchor="middle" class="small">重新加载原图或最新修图结果</text>
<text x="600" y="349" text-anchor="middle" class="small">加载失败保留当前可见内容</text>
<rect x="814" y="258" width="344" height="112" rx="16" class="action"/>
<text x="986" y="292" text-anchor="middle" class="label">预览底部操作</text>
<text x="986" y="323" text-anchor="middle" class="small">保留 AI修图、删除、刷新</text>
<text x="986" y="349" text-anchor="middle" class="small">不展示打印按钮</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="430" viewBox="0 0 1200 430">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.danger { fill: #FFF1F1; stroke: #E35D6A; stroke-width: 2; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="430" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">相册管理多选操作栏</text>
<rect x="42" y="92" width="215" height="110" rx="16" class="box"/>
<text x="149" y="127" text-anchor="middle" class="label">普通浏览状态</text>
<text x="149" y="160" text-anchor="middle" class="small">底部显示“上传照片”</text>
<path d="M257 147 H337" class="line"/>
<rect x="337" y="92" width="215" height="110" rx="16" class="active"/>
<text x="444" y="127" text-anchor="middle" class="label">点击右上角选择</text>
<text x="444" y="160" text-anchor="middle" class="small">缩略图出现单选圆圈</text>
<path d="M552 147 H632" class="line"/>
<rect x="632" y="92" width="225" height="110" rx="16" class="active"/>
<text x="744" y="127" text-anchor="middle" class="label">进入多选状态</text>
<text x="744" y="160" text-anchor="middle" class="small">上传按钮隐藏</text>
<text x="744" y="184" text-anchor="middle" class="small">显示 AI修图、删除</text>
<path d="M857 147 H937" class="line"/>
<rect x="937" y="92" width="221" height="110" rx="16" class="box"/>
<text x="1048" y="127" text-anchor="middle" class="label">选择至少 1 张</text>
<text x="1048" y="160" text-anchor="middle" class="small">两个操作按钮恢复可用</text>
<rect x="140" y="268" width="310" height="110" rx="16" class="active"/>
<text x="295" y="304" text-anchor="middle" class="label">点击 AI修图</text>
<text x="295" y="336" text-anchor="middle" class="small">进入批量 AI 修图弹窗</text>
<text x="295" y="360" text-anchor="middle" class="small">包含处理中照片时按钮置灰</text>
<rect x="750" y="268" width="310" height="110" rx="16" class="danger"/>
<text x="905" y="304" text-anchor="middle" class="label">点击删除</text>
<text x="905" y="336" text-anchor="middle" class="small">二次确认后删除选中素材</text>
<text x="905" y="360" text-anchor="middle" class="small">取消确认则保持原选择</text>
<path d="M744 202 V235 H295 V268" class="line"/>
<path d="M744 202 V235 H905 V268" class="line"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="300" viewBox="0 0 1080 300">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7A8699"/>
</marker>
<style>
.title { font: 600 18px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #1F2937; }
.detail { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #667085; }
.box { fill: #FFFFFF; stroke: #D9E2EF; stroke-width: 2; rx: 16; }
.active { fill: #EEF5FF; stroke: #1677FF; stroke-width: 2; rx: 16; }
</style>
</defs>
<rect width="1080" height="300" fill="#F7F9FC"/>
<rect class="box" x="40" y="104" width="180" height="92"/>
<text class="title" x="130" y="142" text-anchor="middle">相册管理</text>
<text class="detail" x="130" y="170" text-anchor="middle">点击上传照片</text>
<line x1="220" y1="150" x2="292" y2="150" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="active" x="305" y="70" width="230" height="160"/>
<text class="title" x="420" y="108" text-anchor="middle">选择传输模式</text>
<text class="detail" x="420" y="142" text-anchor="middle">边拍边传</text>
<text class="detail" x="420" y="174" text-anchor="middle">拍后传输</text>
<text class="detail" x="420" y="206" text-anchor="middle">取消:留在当前页</text>
<line x1="535" y1="118" x2="622" y2="78" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="535" y1="182" x2="622" y2="222" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="box" x="635" y="28" width="400" height="100"/>
<text class="title" x="835" y="68" text-anchor="middle">有线传输 · 边拍边传</text>
<text class="detail" x="835" y="96" text-anchor="middle">新照片传入后自动上传当前相册</text>
<rect class="box" x="635" y="172" width="400" height="100"/>
<text class="title" x="835" y="212" text-anchor="middle">有线传输 · 拍后传输</text>
<text class="detail" x="835" y="240" text-anchor="middle">拍摄完成后选择照片批量上传</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="470" viewBox="0 0 1200 470">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.optional { fill: #FFF8E8; stroke: #F2A93B; stroke-width: 2; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="470" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">AI 修图弹窗:原图精修、氛围感与结果组合</text>
<rect x="42" y="92" width="190" height="116" rx="16" class="box"/>
<text x="137" y="127" text-anchor="middle" class="label">打开 AI修图</text>
<text x="137" y="158" text-anchor="middle" class="small">显示已选照片数量</text>
<text x="137" y="183" text-anchor="middle" class="small">内容区域可纵向滚动</text>
<path d="M232 150 H272" class="line"/>
<rect x="272" y="78" width="230" height="144" rx="18" class="active"/>
<text x="387" y="116" text-anchor="middle" class="label">原图精修</text>
<text x="387" y="148" text-anchor="middle" class="small">预设文字 + 对应效果图</text>
<text x="387" y="174" text-anchor="middle" class="small">横向滚动,始终单选</text>
<text x="387" y="200" text-anchor="middle" class="small">生成基础“精修后”结果</text>
<path d="M502 150 H542" class="line"/>
<rect x="542" y="78" width="230" height="144" rx="18" class="optional"/>
<text x="657" y="116" text-anchor="middle" class="label">氛围感修图(选填)</text>
<text x="657" y="148" text-anchor="middle" class="small">横向滚动,多样式单选</text>
<text x="657" y="174" text-anchor="middle" class="small">默认不选,重复点击可取消</text>
<text x="657" y="200" text-anchor="middle" class="small">额外生成独立氛围感结果</text>
<path d="M772 150 H812" class="line"/>
<rect x="812" y="92" width="170" height="116" rx="16" class="box"/>
<text x="897" y="127" text-anchor="middle" class="label">只读额度</text>
<text x="897" y="158" text-anchor="middle" class="small">显示剩余 9 张</text>
<text x="897" y="183" text-anchor="middle" class="small">不提供勾选操作</text>
<path d="M982 150 H1014" class="line"/>
<rect x="1014" y="92" width="144" height="116" rx="16" class="active"/>
<text x="1086" y="127" text-anchor="middle" class="label">点击确定</text>
<text x="1086" y="158" text-anchor="middle" class="small">提交样式组合</text>
<text x="1086" y="183" text-anchor="middle" class="small">开始处理</text>
<rect x="42" y="286" width="344" height="120" rx="16" class="box"/>
<text x="214" y="322" text-anchor="middle" class="label">点击取消 / 下拉关闭</text>
<text x="214" y="354" text-anchor="middle" class="small">不启动任务</text>
<text x="214" y="380" text-anchor="middle" class="small">不改变原图、旧结果或额度状态</text>
<rect x="428" y="286" width="344" height="120" rx="16" class="box"/>
<text x="600" y="322" text-anchor="middle" class="label">效果图预览</text>
<text x="600" y="354" text-anchor="middle" class="small">各样式卡片显示“预览”按钮</text>
<text x="600" y="380" text-anchor="middle" class="small">全屏查看,不改变当前选中项</text>
<rect x="814" y="286" width="344" height="120" rx="16" class="box"/>
<text x="986" y="322" text-anchor="middle" class="label">结果独立保存</text>
<text x="986" y="354" text-anchor="middle" class="small">原图 / 精修后 / 氛围感分别查看</text>
<text x="986" y="380" text-anchor="middle" class="small">再次确认后以新组合覆盖旧结果</text>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="300" viewBox="0 0 1080 300">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7A8699"/>
</marker>
<style>
.title { font: 600 18px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #1F2937; }
.detail { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #667085; }
.box { fill: #FFFFFF; stroke: #D9E2EF; stroke-width: 2; rx: 16; }
.active { fill: #EEF5FF; stroke: #1677FF; stroke-width: 2; rx: 16; }
</style>
</defs>
<rect width="1080" height="300" fill="#F7F9FC"/>
<rect class="box" x="40" y="104" width="210" height="92"/>
<text class="title" x="145" y="142" text-anchor="middle">有线传输照片列表</text>
<text class="detail" x="145" y="170" text-anchor="middle">点击任意缩略图</text>
<line x1="250" y1="150" x2="320" y2="150" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="active" x="333" y="70" width="260" height="160"/>
<text class="title" x="463" y="108" text-anchor="middle">多图全屏预览</text>
<text class="detail" x="463" y="142" text-anchor="middle">从被点击照片开始</text>
<text class="detail" x="463" y="174" text-anchor="middle">页码显示 当前张 / 总张数</text>
<text class="detail" x="463" y="206" text-anchor="middle">不可预览照片自动跳过</text>
<line x1="593" y1="112" x2="668" y2="74" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<line x1="593" y1="188" x2="668" y2="226" stroke="#7A8699" stroke-width="2" marker-end="url(#arrow)"/>
<rect class="box" x="681" y="28" width="354" height="100"/>
<text class="title" x="858" y="68" text-anchor="middle">向右滑动</text>
<text class="detail" x="858" y="96" text-anchor="middle">查看上一张,第一张处停止</text>
<rect class="box" x="681" y="172" width="354" height="100"/>
<text class="title" x="858" y="212" text-anchor="middle">向左滑动</text>
<text class="detail" x="858" y="240" text-anchor="middle">查看下一张,最后一张处停止</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.processing { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.success { fill: #EEFAF3; stroke: #37A86B; stroke-width: 2.5; }
.failed { fill: #FFF1F1; stroke: #E35D6A; stroke-width: 2.5; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
.retry { fill: none; stroke: #E35D6A; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="500" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">AI 修图任务生命周期与覆盖规则</text>
<rect x="42" y="104" width="205" height="116" rx="16" class="box"/>
<text x="144" y="140" text-anchor="middle" class="label">可发起</text>
<text x="144" y="172" text-anchor="middle" class="small">未修图或已有旧结果</text>
<text x="144" y="198" text-anchor="middle" class="small">AI修图按钮可点击</text>
<path d="M247 162 H337" class="line"/>
<rect x="337" y="104" width="230" height="116" rx="16" class="processing"/>
<text x="452" y="140" text-anchor="middle" class="label">修图中</text>
<text x="452" y="172" text-anchor="middle" class="small">按钮置灰,禁止重复提交</text>
<text x="452" y="198" text-anchor="middle" class="small">离开页面后任务继续完成</text>
<path d="M567 142 H692" class="line"/>
<path d="M567 182 H692" class="retry"/>
<rect x="692" y="76" width="230" height="116" rx="16" class="success"/>
<text x="807" y="112" text-anchor="middle" class="label">成功</text>
<text x="807" y="144" text-anchor="middle" class="small">分别保存精修与氛围感结果</text>
<text x="807" y="170" text-anchor="middle" class="small">默认展示“精修后”</text>
<rect x="692" y="228" width="230" height="116" rx="16" class="failed"/>
<text x="807" y="264" text-anchor="middle" class="label">失败</text>
<text x="807" y="296" text-anchor="middle" class="small">显示“修图失败”</text>
<text x="807" y="322" text-anchor="middle" class="small">恢复 AI修图按钮,可重试</text>
<path d="M922 134 H1012" class="line"/>
<rect x="1012" y="76" width="146" height="116" rx="16" class="box"/>
<text x="1085" y="112" text-anchor="middle" class="label">再次修图</text>
<text x="1085" y="144" text-anchor="middle" class="small">新组合完成后</text>
<text x="1085" y="170" text-anchor="middle" class="small">整组覆盖旧结果</text>
<path d="M1085 192 V405 H144 V220" class="line"/>
<path d="M807 344 V405 H452 V220" class="retry"/>
<rect x="337" y="380" width="230" height="82" rx="16" class="box"/>
<text x="452" y="414" text-anchor="middle" class="label">还原为原图</text>
<text x="452" y="440" text-anchor="middle" class="small">清除精修、氛围感、封面及失败状态</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="500" viewBox="0 0 1200 500">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#4D7CFE"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 600 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.box { fill: #FFFFFF; stroke: #B9C8DA; stroke-width: 2; }
.active { fill: #EEF4FF; stroke: #4D7CFE; stroke-width: 2.5; }
.free { fill: #FFF8E8; stroke: #F2A93B; stroke-width: 2.5; }
.cover { fill: #FFF4E8; stroke: #F59E0B; stroke-width: 2.5; }
.line { fill: none; stroke: #4D7CFE; stroke-width: 2.5; marker-end: url(#arrow); }
</style>
</defs>
<rect width="1200" height="500" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">多图三类风格选择与封面生成</text>
<rect x="42" y="92" width="220" height="116" rx="16" class="box"/>
<text x="152" y="128" text-anchor="middle" class="label">统计选中照片数</text>
<text x="152" y="160" text-anchor="middle" class="small">进入批量 AI 修图前判断</text>
<text x="152" y="186" text-anchor="middle" class="small">阈值以当前需求为准</text>
<path d="M262 150 H342" class="line"/>
<rect x="342" y="76" width="230" height="148" rx="18" class="active"/>
<text x="457" y="112" text-anchor="middle" class="label">数量是否 ≥ 4 张?</text>
<text x="457" y="147" text-anchor="middle" class="small">否:显示精修 + 氛围感</text>
<text x="457" y="175" text-anchor="middle" class="small">是:再显示封面模板</text>
<text x="457" y="201" text-anchor="middle" class="small">弹窗自动使用更高高度</text>
<path d="M572 150 H652" class="line"/>
<rect x="652" y="76" width="250" height="148" rx="18" class="free"/>
<text x="777" y="112" text-anchor="middle" class="label">显示三个风格模块</text>
<text x="777" y="147" text-anchor="middle" class="small">原图精修:单选基础预设</text>
<text x="777" y="175" text-anchor="middle" class="small">氛围感:横向单选,可取消</text>
<text x="777" y="201" text-anchor="middle" class="small">封面:横向单选,可全屏预览</text>
<path d="M902 150 H982" class="line"/>
<rect x="982" y="92" width="176" height="116" rx="16" class="cover"/>
<text x="1070" y="128" text-anchor="middle" class="label">确认并生成</text>
<text x="1070" y="160" text-anchor="middle" class="small">每张保存独立结果</text>
<text x="1070" y="186" text-anchor="middle" class="small">第一张另作封面</text>
<rect x="42" y="300" width="344" height="126" rx="16" class="box"/>
<text x="214" y="336" text-anchor="middle" class="label">当前免费阶段</text>
<text x="214" y="368" text-anchor="middle" class="small">封面 quotaCost = 0,不减少额度</text>
<text x="214" y="394" text-anchor="middle" class="small">弹窗显示“赠送 · 不占精修张数”</text>
<rect x="428" y="300" width="344" height="126" rx="16" class="box"/>
<text x="600" y="336" text-anchor="middle" class="label">未来收费阶段</text>
<text x="600" y="368" text-anchor="middle" class="small">服务端将 quotaCost 配置为正数</text>
<text x="600" y="394" text-anchor="middle" class="small">确认前展示实际消耗,成功后再扣减</text>
<rect x="814" y="300" width="344" height="126" rx="16" class="box"/>
<text x="986" y="336" text-anchor="middle" class="label">重复选择封面模板</text>
<text x="986" y="368" text-anchor="middle" class="small">新封面效果覆盖旧封面效果</text>
<text x="986" y="394" text-anchor="middle" class="small">失败时不把结果标记为已生成</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="520" viewBox="0 0 1200 520">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
<path d="M0,0 L0,6 L9,3 z" fill="#7A8CA5"/>
</marker>
<style>
.title { font: 700 24px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.label { font: 700 17px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #182230; }
.small { font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #5D6B7C; }
.card { fill: #FFFFFF; stroke: #D5DEE9; stroke-width: 2; }
.line { fill: none; stroke: #7A8CA5; stroke-width: 2.3; marker-end: url(#arrow); }
.badge { font: 700 15px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; fill: #FFFFFF; }
</style>
</defs>
<rect width="1200" height="520" rx="24" fill="#F7F9FC"/>
<text x="42" y="52" class="title">相册缩略图结果类型与状态优先级</text>
<rect x="42" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="58" y="114" width="70" height="28" rx="8" fill="#34C759"/>
<text x="93" y="134" text-anchor="middle" class="badge">已上传</text>
<text x="137" y="178" text-anchor="middle" class="label">普通原图</text>
<text x="137" y="210" text-anchor="middle" class="small">显示服务端缩略图</text>
<path d="M232 172 H282" class="line"/>
<rect x="282" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="298" y="114" width="70" height="28" rx="8" fill="#4D7CFE"/>
<text x="333" y="134" text-anchor="middle" class="badge">修图中</text>
<text x="377" y="178" text-anchor="middle" class="label">处理中</text>
<text x="377" y="210" text-anchor="middle" class="small">AI入口同步置灰</text>
<path d="M472 172 H522" class="line"/>
<rect x="522" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="538" y="114" width="70" height="28" rx="8" fill="#6750A4"/>
<text x="573" y="134" text-anchor="middle" class="badge">AI已修</text>
<text x="617" y="178" text-anchor="middle" class="label">普通修图结果</text>
<text x="617" y="210" text-anchor="middle" class="small">缩略图展示最新结果</text>
<path d="M712 172 H762" class="line"/>
<rect x="762" y="98" width="190" height="148" rx="18" class="card"/>
<rect x="778" y="114" width="78" height="28" rx="8" fill="#F59E0B"/>
<text x="817" y="134" text-anchor="middle" class="badge">AI封面</text>
<text x="857" y="178" text-anchor="middle" class="label">封面结果</text>
<text x="857" y="210" text-anchor="middle" class="small">显示封面模板排版图</text>
<path d="M952 172 H1002" class="line"/>
<rect x="1002" y="98" width="156" height="148" rx="18" class="card"/>
<rect x="1018" y="114" width="88" height="28" rx="8" fill="#E53935"/>
<text x="1062" y="134" text-anchor="middle" class="badge">修图失败</text>
<text x="1080" y="178" text-anchor="middle" class="label">失败结果</text>
<text x="1080" y="210" text-anchor="middle" class="small">可再次修图</text>
<rect x="42" y="310" width="1116" height="150" rx="18" fill="#FFFFFF" stroke="#D5DEE9" stroke-width="2"/>
<text x="70" y="346" class="label">状态显示优先级</text>
<text x="70" y="382" class="small">修图中 > 修图失败 > AI封面 > AI已修 > 已上传。高优先级状态先展示,避免处理中或失败被旧成功结果遮盖。</text>
<text x="70" y="416" class="small">成功:缩略图切换为最新修图图或封面图;再次成功:覆盖旧结果;还原原图:回到“已上传”;删除素材:同步清除本地结果。</text>
<text x="70" y="446" class="small">当前 AI 修图、封面结果及角标为本地演示数据,App 进程重启后会清空;服务端接入后应返回结果类型、状态、结果图地址和模板 ID。</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+484
View File
@@ -0,0 +1,484 @@
# 后端消息推送格式
## 1. 文档说明
- 本文整理后端经极光推送发送到 Android 和 iOS 的通知消息格式。
- 当前内容基于截至 2026-07-23 的 Android、iOS 实际收包日志、两端客户端现有解析逻辑,以及后端开发人员提供的 `PushMsg` 类型定义。
- 后端当前定义了 `1`~`13`、`100`、`999` 共 15 个业务消息类型;目前已取得 `type = 1`(付款成功)、`type = 2`(退款成功)、`type = 6`(扫码成功)和 `type = 13`(新获客线索)的收包样本。
- 本文描述的是推送协议,不是普通 HTTP 接口响应格式。
## 2. 统一业务结构
极光在 Android 和 iOS 上交付的原始结构不同,但当前消息都可以归一化为以下业务结构:
```json
{
"title": "通知标题",
"content": "通知正文",
"msg_id": 1858,
"type": 6,
"data": {
"order_type": 14
}
}
```
### 2.1 公共业务字段
| 字段 | 当前类型 | 说明 |
| --- | --- | --- |
| `title` | string | 通知标题。Android 为 `notificationTitle`,iOS 为 `aps.alert.title`。 |
| `content` | string | 通知正文。Android 为 `notificationContent`,iOS 为 `aps.alert.body`。 |
| `msg_id` | int | 后端业务消息 ID。它与极光消息 ID 不是同一个字段。 |
| `type` | int | 业务消息类型,是客户端业务分发的主要依据。 |
| `data` | object | 随消息类型变化的业务数据。 |
> “当前类型”表示现有样本中观察到的 JSON 类型。后端应保持字段类型稳定,避免同一个字段在不同推送中交替使用数字、字符串或 `null`。
## 3. Android 收包格式
Android 通过极光 `JPushMessageReceiver.onNotifyMessageArrived` 收到通知。标题、正文和附加字段分别由 SDK 提供,其中 `extras` 是 JSON 字符串:
```text
[onNotifyMessageArrived] msgId=<极光消息ID>, title=<标题>, content=<正文>, extras=<业务JSON字符串>
```
`extras` 的当前结构为:
```json
{
"data": {},
"msg_id": 0,
"type": 0
}
```
### 3.1 扫码成功原始日志
```text
[onNotifyMessageArrived] msgId=18103321643292031, title=扫码成功通知, content=扫码成功, extras={"data":{"order_type":14},"msg_id":1852,"type":6}
```
对应的 `extras`:
```json
{
"data": {
"order_type": 14
},
"msg_id": 1852,
"type": 6
}
```
### 3.2 付款成功原始日志
```text
[onNotifyMessageArrived] msgId=18103321644290274, title=付款成功通知, content=您的订单 #260722128005 已支付成功,支付金额 ¥0.01, extras={"data":{"order_amount":"0.01","order_number":"260722128005","order_type":14,"pay_time":1784711036,"remark":""},"msg_id":1853,"type":1}
```
对应的 `extras`:
```json
{
"data": {
"order_amount": "0.01",
"order_number": "260722128005",
"order_type": 14,
"pay_time": 1784711036,
"remark": ""
},
"msg_id": 1853,
"type": 1
}
```
### 3.3 退款成功原始日志
```text
[onNotifyMessageArrived] msgId=18103321800527159, title=退款成功通知, content=您的订单 #260722128002 已成功退款,退款金额 ¥0.01, extras={"data":{},"msg_id":1867,"type":2}
```
对应的 `extras`:
```json
{
"data": {},
"msg_id": 1867,
"type": 2
}
```
## 4. iOS 收包格式
iOS 通过 APNs/极光收到 `userInfo` 字典。与 Android 相比:
- 标题和正文位于 `aps.alert`。
- Android `extras` 中的 `msg_id`、`type`、`data` 在 iOS 中位于 payload 顶层。
- `_j_*` 为极光传输层内部字段,不应作为业务分发依据。
通用结构如下:
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321726881995,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "通知正文",
"title": "通知标题"
},
"sound": "default"
},
"data": {},
"msg_id": 0,
"type": 0
}
```
> 原始日志中该内部字段显示为 `*j\_data*`。本文按极光其他内部字段的命名方式记为 `_j_data`;后续联调时应以设备收到的真实字典 key 为准。该字段目前不参与业务解析。
### 4.1 扫码成功 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321726881995,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "扫码成功",
"title": "扫码成功通知"
},
"sound": "default"
},
"data": {
"order_type": 14
},
"msg_id": 1858,
"type": 6
}
```
### 4.2 付款成功 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321720587147,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "您的订单 #260722128006 已支付成功,支付金额 ¥0.01",
"title": "付款成功通知"
},
"sound": "default"
},
"data": {
"order_amount": "0.01",
"order_number": "260722128006",
"order_type": 14,
"pay_time": 1784712671,
"remark": ""
},
"msg_id": 1856,
"type": 1
}
```
### 4.3 退款成功 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103321792603316,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "您的订单 #260722128005 已成功退款,退款金额 ¥0.01",
"title": "退款成功通知"
},
"sound": "default"
},
"data": {},
"msg_id": 1862,
"type": 2
}
```
### 4.4 新获客线索 payload
```json
{
"_j_business": 1,
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
"_j_msgid": 18103323696468714,
"_j_uid": 86399144805,
"aps": {
"alert": {
"body": "获客员哦啦啦提交了一条新线索,请及时查看并跟进",
"title": "新获客线索通知"
},
"sound": "default"
},
"data": {
"lead_id": 75,
"sale_user_id": 6,
"saler_name": "哦啦啦"
},
"msg_id": 1869,
"type": 13
}
```
## 5. Android 与 iOS 字段对应关系
| 语义 | Android | iOS | 是否用于业务 |
| --- | --- | --- | --- |
| 极光消息 ID | SDK `message.msgId` | `_j_msgid` | 否,仅用于推送链路日志与排查 |
| 通知标题 | SDK `message.notificationTitle` | `aps.alert.title` | 是,用于展示 |
| 通知正文 | SDK `message.notificationContent` | `aps.alert.body` | 是,用于展示 |
| 后端业务消息 ID | `extras.msg_id` | 顶层 `msg_id` | 是 |
| 业务消息类型 | `extras.type` | 顶层 `type` | 是,作为业务分发主键 |
| 业务数据 | `extras.data` | 顶层 `data` | 是,结构由 `type` 决定 |
| 极光业务标记 | SDK 内部处理 | `_j_business` | 否 |
| 极光附加元数据 | SDK 内部处理 | `_j_data` | 否 |
| 极光用户 ID | SDK 内部处理 | `_j_uid` | 否 |
| APNs 展示配置 | 不适用 | `aps` | 仅 iOS 系统使用 |
### 5.1 两种消息 ID 不可混用
- `msgId` / `_j_msgid`:极光生成的传输层消息 ID,当前样本为 17 位数字。
- `msg_id`:后端生成的业务消息 ID,当前样本为 `1852`、`1853`、`1856`、`1858`。
- 客户端日志、去重或上报时必须明确需要哪一种 ID,不能仅用名称相近而互相替代。
- 极光消息 ID 已超过 JavaScript 可安全表示的整数范围;若需要经过 JavaScript、WebView 或其他只能安全处理 53 位整数的链路,应按字符串传递。
## 6. 消息类型清单
### 6.1 后端 `PushMsg` 完整类型定义
定义位置:后端 `PushMsg.php` 第 49 行附近。
| `type` | 消息类型 | 后端备注 | 当前收包样本 |
| ---: | --- | --- | --- |
| `1` | 付款成功通知 | 上文实际示例即为此类型 | Android、iOS 均已取得 |
| `2` | 退款成功通知 | — | Android、iOS 均已取得 |
| `3` | 下单成功通知 | App 无需推送,当前未使用 | 暂无 |
| `4` | 签到成功通知 | — | 暂无 |
| `5` | 付尾款成功通知 | — | 暂无 |
| `6` | 扫码成功通知 | — | Android、iOS 均已取得 |
| `7` | 付定金成功通知 | — | 暂无 |
| `8` | 订单核销成功通知 | — | 暂无 |
| `9` | 剪辑完成通知 | — | 暂无 |
| `10` | 修图完成通知 | — | 暂无 |
| `11` | 跟拍签到通知 | — | 暂无 |
| `12` | 剪辑师新任务通知 | 类型名称映射中漏配 | 暂无 |
| `13` | 新获客线索通知 | 获客员提交新线索 | iOS 已取得,Android 暂无 |
| `100` | 用户登出通知 | 账号在其他设备登录 | 暂无 |
| `999` | 系统消息 | 当前未使用 | 暂无 |
后端特别说明:
- `type` 表示业务消息类型,不代表推送渠道。
- 推送渠道的取值另有定义:`1` 为极光推送,`2` 为微信小程序,`999` 为不推送。
- `type = 12` 虽然已经定义,但尚未加入后端 `MAP_TYPE_NAMES`。后端调用 `getTypeName(12)` 时会返回“未知消息类型”,需要后端补充映射。
- “暂无收包样本”只表示当前文档还没有该类型的完整 payload,不能据此认为该类型未启用。收到后应继续补充其标题、正文、`data` 字段和点击行为。
### 6.2 `type = 1`:付款成功
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `付款成功通知` |
| 正文示例 | `您的订单 #260722128006 已支付成功,支付金额 ¥0.01` |
| 客户端点击行为 | Android、iOS 均进入收款记录页 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| `order_amount` | string | `"0.01"` | 支付金额。使用十进制字符串,避免浮点精度问题。 |
| `order_number` | string | `"260722128006"` | 业务订单号。 |
| `order_type` | int | `14` | 订单类型;当前值表示“摄影师跟拍线下扫码”。 |
| `pay_time` | int | `1784712671` | 支付时间。当前样本为 Unix 秒级时间戳;Android 现有代码同时兼容秒和毫秒。 |
| `remark` | string | `""` | 付款备注;没有备注时当前返回空字符串。 |
示例:
```json
{
"type": 1,
"msg_id": 1856,
"data": {
"order_amount": "0.01",
"order_number": "260722128006",
"order_type": 14,
"pay_time": 1784712671,
"remark": ""
}
}
```
### 6.3 `type = 2`:退款成功
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `退款成功通知` |
| 正文示例 | `您的订单 #260722128005 已成功退款,退款金额 ¥0.01` |
| 客户端点击行为 | Android、iOS 均进入消息中心 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| — | object | `{}` | 当前 Android、iOS 样本均为空对象。 |
示例:
```json
{
"type": 2,
"msg_id": 1862,
"data": {}
}
```
> 当前订单号和退款金额只存在于展示正文中,没有以结构化字段放入 `data`。客户端如果需要可靠地刷新指定订单、展示退款详情或执行金额计算,应由后端在 `data` 中提供字段,不应解析自然语言正文。
### 6.4 `type = 6`:扫码成功
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `扫码成功通知` |
| 正文示例 | `扫码成功` |
| 客户端点击行为 | Android、iOS 均进入收款详情页 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| `order_type` | int | `14` | 订单类型;`14` 在 Android 现有代码中表示“摄影师跟拍线下扫码”。 |
示例:
```json
{
"type": 6,
"msg_id": 1858,
"data": {
"order_type": 14
}
}
```
### 6.5 `type = 13`:新获客线索
| 项目 | 当前值 |
| --- | --- |
| 标题示例 | `新获客线索通知` |
| 正文示例 | `获客员哦啦啦提交了一条新线索,请及时查看并跟进` |
| 客户端点击行为 | Android、iOS 均进入消息中心 |
`data` 字段:
| 字段 | 类型 | 当前样本 | 说明 |
| --- | --- | --- | --- |
| `lead_id` | int | `75` | 新增获客线索的唯一 ID。 |
| `sale_user_id` | int | `6` | 提交线索的获客员 ID。 |
| `saler_name` | string | `"哦啦啦"` | 获客员名称,与通知正文中的名称一致。 |
示例:
```json
{
"type": 13,
"msg_id": 1869,
"data": {
"lead_id": 75,
"sale_user_id": 6,
"saler_name": "哦啦啦"
}
}
```
> 当前点击路由只根据 `type` 判断,`type = 13` 属于默认分支,因此进入消息中心;`data` 中的线索字段不会改变跳转页面。
## 7. 客户端解析约定
1. Android、iOS 的通知点击路由只根据业务字段 `type` 判断,不读取标题、正文、`data`、`route`、`uri`、`action` 或极光内部字段决定页面。
2. `type = 1`:进入收款记录页。
3. `type = 6`:进入收款详情页。
4. 其他任意 `type`、缺少 `type`、类型无法解析或 payload 损坏:进入消息中心。
5. Android 从 `notificationExtras` 或厂商通道包装字段中提取 `type`;iOS 优先读取 payload 顶层 `type`,并兼容常见推送包装层中的 `type`。
6. `data` 仍可按消息类型用于页面内容刷新,但不参与通知点击路由。新增字段应保持向后兼容,客户端应忽略当前版本不认识的额外字段。
7. 金额继续使用字符串;时间戳建议统一为 Unix 秒级整数,并在协议中固定单位。
8. `remark` 等可空业务字段建议固定返回空字符串或明确约定 `null`,不要在多种表示之间切换。
## 8. 新增消息类型记录模板
后续收到新类型时,复制以下内容追加到“消息类型清单”:
````markdown
### 6.x `type = <类型值>`:<消息名称>
| 项目 | 当前值 |
| --- | --- |
| 首次发现日期 | `YYYY-MM-DD` |
| 标题示例 | `<title>` |
| 正文示例 | `<content>` |
| 客户端业务行为 | `<展示、刷新或跳转行为>` |
`data` 字段:
| 字段 | 类型 | 必有 | 说明 |
| --- | --- | --- | --- |
| `<field>` | `<type>` | `<是/否/待确认>` | `<说明>` |
Android `extras` 示例:
```json
{
"data": {},
"msg_id": 0,
"type": 0
}
```
iOS 业务字段示例:
```json
{
"data": {},
"msg_id": 0,
"type": 0
}
```
````
记录新类型时至少保留以下信息:
- Android 和 iOS 各一份原始收包日志。
- `type`、`msg_id` 和完整 `data`。
- 标题、正文以及消息点击后的期望页面。
- 每个字段的 JSON 类型、是否必有、空值表示方式和时间/金额单位。
- 同一业务事件在两端的字段是否一致。
## 9. 待后端确认项
| 项目 | 当前观察 | 需要确认 |
| --- | --- | --- |
| `msg_id` | 与极光消息 ID 不同 | 是否对应消息中心记录 ID,以及是否用于已读/送达状态上报 |
| `data` | `type = 1`、`2`、`6`、`13` 的样本中都存在;`type = 2` 为 `{}` | 所有业务推送是否保证存在;无业务字段时是否统一返回 `{}` |
| 退款成功的 `data` | 当前为空对象,订单号和退款金额只在正文中 | 是否补充 `order_number`、`refund_amount`、`refund_time`、`order_type` 等结构化字段 |
| 其他类型的 `data` | 后端已定义消息名称,但尚未提供 payload | 分别确认 `type = 3`~`5`、`7`~`12`、`100`、`999` 的字段结构、必填项及点击行为 |
| `pay_time` | 当前为秒级时间戳 | 后端是否统一保证使用 Unix 秒,且时区语义固定为 UTC 时间戳 |
| `remark` | 无备注时为空字符串 | 是否可能返回 `null` 或省略 |
| `order_type` | 当前为 `14` | 完整枚举和值含义由哪份接口文档维护 |
| 标题与正文 | 当前均存在 | 是否为所有通知消息的必有字段 |
| 推送渠道字段 | 后端已给出渠道取值 `1`、`2`、`999` | payload 或发送接口中对应字段的准确名称 |
| `type = 12` | `MAP_TYPE_NAMES` 中漏配 | 后端补充映射,避免 `getTypeName(12)` 返回“未知消息类型” |
| 新增 `type` | 当前定义维护在 `PushMsg.php` | 后端新增类型前是否可同步类型值、字段结构和客户端行为 |
+281
View File
@@ -0,0 +1,281 @@
# 相册管理与 AI 修图功能说明
> 文档日期:2026-08-20
> 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图
> 数据说明:相册和照片列表、有线相机连接及原图上传继续使用项目已有真实数据能力;传输模式与相册自动修图模板保存在本地。自动修图目前使用本地图像效果演示,修图结果图和任务状态可随 OTG 相册恢复;手动原图精修、氛围感、封面模板和剩余 9 张额度仍为本地演示数据。
## 1. 照片预览、左右滑动与底部操作
### 功能说明
用户在“相册管理”照片网格中点击任意缩略图,进入单张照片预览页。页面只保留一个返回按钮,导航区域展示当前照片文件名和文件大小;照片区域支持左右滑动查看同一相册内的上一张或下一张照片。
预览页统一使用黑色沉浸式自定义头部,不再混用系统导航栏:左侧为圆形返回按钮,中间展示文件名和文件大小,右侧展示“当前张数/总张数”。切换照片时三处信息同步更新。照片预览与原图精修、氛围感、封面模板预览都直接使用同一个全屏模态容器,不进入项目导航栈,因此不会叠加系统白色导航栏或出现两个返回按钮;关闭后回到进入前的相册管理页或修图弹窗。
底部固定展示以下三个操作,不展示打印按钮:
| 操作 | 点击结果 | 操作期间或异常处理 |
| --- | --- | --- |
| AI修图 | 打开当前照片的 AI 修图弹窗 | 当前照片处于“修图中”时按钮置灰,避免重复提交 |
| 删除 | 二次确认后删除当前照片 | 删除成功后自动定位到相邻照片;删除最后一张后返回相册管理 |
| 刷新 | 重新加载当前照片及最新修图结果 | 刷新失败时不清空当前已显示内容,可再次点击 |
左右滑动时同步刷新文件名、文件大小、原图/修图后状态和底部按钮状态。第一张继续向右滑、最后一张继续向左滑时保持当前照片,不循环跳转。
页面底部图标使用项目内 `TravelAlbumPreviewIcons` SVG 资源,确保 AI 修图、删除、刷新三个入口视觉一致。
### 状态流转图
![照片预览与左右切换流程](./assets/travel-album-ai/01-photo-preview-flow.svg)
### 本功能验收
- [ ] 从任意缩略图进入时只显示一个返回入口。
- [ ] 文件名和文件大小与当前照片一致。
- [ ] 导航头部保持黑色沉浸样式,右侧张数随照片切换同步变化。
- [ ] 左右滑动后照片和文件信息同步变化。
- [ ] 首尾照片不会越界或循环。
- [ ] 底部仅显示 AI修图、删除、刷新,不显示打印。
- [ ] 删除当前照片后能正确定位或退出预览。
## 2. 上传照片与传输模式选择
### 功能说明
相册管理处于普通状态时,用户点击底部“上传照片”,先从底部弹出“选择传输模式”,展示“边拍边传”和“拍后传输”两个选项。点击任一选项后弹窗关闭,并进入当前相册的有线传输页面;点击“取消”或向下关闭弹窗时停留在相册管理,不改变现有照片和选择状态。
| 传输模式 | 弹窗说明 | 下一页初始状态 | 后续行为 |
| --- | --- | --- | --- |
| 边拍边传 | 相机拍摄后,照片自动传输并上传到当前相册 | 传输模式显示“边拍边传” | 新传入 App 的照片自动上传 |
| 拍后传输 | 拍摄完成后,再选择相机照片批量传输 | 传输模式显示“拍后传输” | 新照片先保留,用户后续选择并上传 |
本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。
### 自动 AI 修图
新建相册时在价格字段下方固定展示“不修图”和“AI 修图”两个选项,默认选中“不修图”。点击“AI 修图”后再从底部弹出模板选择;每个模板同时展示本地演示照片套用该效果后的缩略图和效果说明。未选模板时不会开启 AI 修图。相册创建成功后,配置按真实相册 ID 保存,上传页仅回显当前一级方式“不修图 / AI 修图”。
有线传输页顶部的设置项也仅回显“不修图 / AI 修图”。点击后底部弹窗的第一层始终只有这两个选项;选择“AI 修图”后进入带效果缩略图的模板列表,可返回第一层重新选择。新配置只影响尚未开始的上传任务。每张照片仍先按原流程上传原图,得到服务端素材 ID 后才执行自动修图:
| 阶段 | 列表展示 | 统计归类 | 失败处理 |
| --- | --- | --- | --- |
| 原图上传 | 蓝色“上传中”及进度 | 全部 | 进入上传失败,可重传 |
| 自动修图 | 蓝色“修图中” | 全部 | 不回退原图上传结果 |
| 修图成功 | 绿色“已上传”,缩略图右上角显示紫色“修” | 已上传 | 使用修图后结果预览 |
| 修图失败 | 红色“修图失败” | 已上传 | 更多菜单选择“重新修图”,不重复上传原图 |
修图中退出 App 时,再次进入会恢复为“修图失败”并允许重试,避免任务长期停留在处理中。
进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。
### 状态流转图
![上传照片与传输模式选择流程](./assets/travel-album-ai/02-upload-transfer-mode-flow.svg)
![有线传输缩略图预览流程](./assets/travel-album-ai/03-wired-transfer-preview-flow.svg)
### 本功能验收
- [ ] 点击“上传照片”后先显示底部传输模式弹窗,不直接进入下一页。
- [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。
- [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。
- [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。
- [ ] 新建相册固定展示“不修图”和“AI 修图”两个选项。
- [ ] 选择“AI 修图”后才弹出模板列表,每个模板都能看到差异化效果图。
- [ ] 上传页一级弹窗只有两种修图方式,新配置仅影响后续上传。
- [ ] 修图中、修图成功“修”角标和修图失败三种状态展示正确。
- [ ] 修图失败仍计入“已上传”,点击“重新修图”不重复上传原图。
- [ ] 本次选择不会被上一次保存的模式覆盖。
- [ ] 取消或关闭弹窗后仍停留在相册管理。
- [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。
- [ ] 预览可左右滑动查看当前 Tab 中的上一张和下一张,页码同步变化且首尾不循环。
- [ ] 批量选择状态点击照片只改变选择状态,不打开预览。
## 3. 相册管理多选与底部操作栏切换
### 功能说明
相册管理页普通状态下,底部显示“上传照片”。用户点击右上角选择按钮后进入多选状态,每张缩略图右上角显示选择圆圈,底部“上传照片”隐藏,替换为“AI修图”和“删除”。
| 多选状态 | 页面表现 | 可用操作 | 下一状态 |
| --- | --- | --- | --- |
| 未进入多选 | 不显示选择圆圈,底部显示上传照片 | 点击照片进入预览;点击右上角选择 | 进入多选 |
| 已进入但未选照片 | 显示未选圆圈,AI修图和删除置灰 | 选择照片;再次点击右上角选择退出 | 已选择或退出 |
| 已选择照片 | 选中照片显示勾选,AI修图和删除可用 | 发起批量修图或批量删除 | 弹窗、处理中或删除结果 |
| 选中项含修图中的照片 | 保留当前选择,AI修图置灰 | 等待任务完成;删除仍按现有删除规则处理 | 任务结束后恢复 |
| 退出多选 | 清空本次选择,恢复上传照片 | 上传或进入照片预览 | 普通状态 |
批量删除必须二次确认;用户取消时不删除任何照片并保留当前选择。批量 AI 修图复用与单张预览相同的预设、任务状态和结果覆盖逻辑。
### 状态流转图
![相册管理多选操作栏流程](./assets/travel-album-ai/02-multi-select-flow.svg)
### 本功能验收
- [ ] 进入多选后上传按钮消失,AI修图和删除出现。
- [ ] 未选择照片时两个批量按钮不可点击。
- [ ] 选择至少一张后两个批量按钮恢复可用。
- [ ] 包含修图中照片时不能再次发起 AI 修图。
- [ ] 退出多选后选择状态清空并恢复上传按钮。
## 4. AI 修图弹窗、原图精修、氛围感与按张收费模式
### 功能说明
用户从单张预览或相册多选操作栏点击“AI修图”后,以同一个底部弹窗展示 AI 修图配置。标题与弹窗顶部保留足够间距,标题下方展示“已选择 N 张照片”。弹窗内容可纵向滚动,取消和确定按钮固定在底部,照片较多并出现封面模块时也不会遮挡选择项。
“原图精修”是基础修图模块,使用横向滚动单选列表,每个预设同时展示名称和对应效果图:
| 预设 | 当前效果用途 |
| --- | --- |
| 还原为原图 | 清除该照片已有修图结果和封面类型 |
| 写真-简约肖像 | 低饱和、提亮的人像效果 |
| 写真-清冷古风 | 清冷复古效果 |
| 旅拍-锦绣 | 提升饱和度和对比度 |
| 旅拍-远山 | 低饱和、柔和远景效果 |
| 旅拍-薄雾 | 提亮并降低对比度 |
| 油画-夏日 | 偏暖夏日效果 |
| 油画-浓郁 | 高饱和、高对比效果 |
原图精修默认选中“还原为原图”,用户点击其他预设后仅保留一个选中项。没有可用原图时,效果图位置显示稳定占位图,不影响弹窗选择。每张样式效果图右下角都有“预览”按钮,点击后全屏查看当前照片应用该样式的效果,不改变当前选中项。
“氛围感修图”位于原图精修下方,并明确标记为“选填”。该模块与原图精修保持一致,使用横向滚动效果图单选,当前本地演示包含日落暖阳、复古胶片、清透蓝调、森系薄雾、浓郁故事五种样式。默认不选中;点击一种样式后仅保留该项,再次点击当前样式即可取消。每个样式同样提供“预览”按钮。选中后不是把氛围效果叠加并合并到精修图中,而是为每张照片额外生成一张可独立查看的“氛围感”结果。
原图精修、氛围感、封面模板和“查看高清图”统一进入同一个沉浸式大图预览页。大图页不再套用系统导航栏,只显示一个返回按钮、当前样式名称和“双指缩放查看细节”提示;关闭后回到原弹窗或照片页,并保留关闭前的选择状态。
弹窗内的 Tips 会随当前组合实时变化:
| 当前选择 | 确认后的结果 |
| --- | --- |
| 仅原图精修 | 每张照片生成 1 个“精修后”结果 |
| 原图精修 + 氛围感修图 | 每张照片生成“精修后 + 氛围感”2 个独立结果 |
| 有封面、未选氛围感 | 每张照片生成精修结果,第一张选中照片额外生成封面 |
| 有封面、已选氛围感 | 每张照片生成精修和氛围感 2 个结果,第一张选中照片再额外生成封面 |
“修图模式”区域只读展示“AI精修 · 按张收费”和“剩余 9 张”,不再显示勾选圆圈,也不能点击选择。当前剩余数量为本地演示数据,不会真实扣减额度;未来接入服务端时由接口返回剩余额度,并以任务创建成功或服务端明确返回的扣减结果为准。
点击“确定”提交当前原图精修、氛围感和封面组合并关闭弹窗;点击“取消”或关闭弹窗时不创建任务、不改变旧修图结果,也不改变额度。
### 状态流转图
![AI 修图弹窗流程](./assets/travel-album-ai/03-ai-edit-sheet-flow.svg)
### 本功能验收
- [ ] 标题与弹窗顶部间距自然,不贴近顶部。
- [ ] 已选照片数量准确。
- [ ] 原图精修预设同时展示文字和效果图,并可横向滚动、保持单选。
- [ ] 原图精修、氛围感和封面模板效果图均可通过小按钮全屏预览,预览不改变选择。
- [ ] 所有大图预览使用同一黑色头部、单一返回入口,并支持双指缩放。
- [ ] 氛围感修图横向展示多个效果图,默认不选中、保持单选,可再次点击当前样式取消。
- [ ] 同时选择原图精修和氛围感时,每张照片生成两个独立结果。
- [ ] Tips 能根据氛围感和封面选择动态说明实际输出结果。
- [ ] 修图模式只展示按张收费与剩余张数,不提供选择操作。
- [ ] 取消弹窗不会创建任务或覆盖旧结果。
## 5. AI 修图任务状态、失败重试与结果覆盖
### 功能说明
用户确认修图后,所选照片立即进入“修图中”。任务未结束前,相关 AI 修图入口置灰;即使用户离开照片预览页,本地演示任务仍会继续完成,并通过共享结果状态刷新相册缩略图。
| 任务状态 | 进入条件 | 页面表现 | 可用操作 | 后续状态 |
| --- | --- | --- | --- | --- |
| 可发起 | 未修图、已有成功结果或上次任务已结束 | AI修图按钮可用 | 选择预设并确认 | 修图中 |
| 修图中 | 已提交预设,等待结果 | 按钮置灰;缩略图显示“修图中” | 查看其他照片、离开页面 | 成功或失败 |
| 成功 | 获取原图并完成本地效果处理 | 分别保存精修、氛围感和封面结果;默认展示“精修后”;按钮恢复 | 在已有结果间切换、再次修图、还原原图 | 新任务或已上传 |
| 失败 | 原图无法获取或处理失败 | 缩略图显示“修图失败”;按钮恢复 | 再次修图 | 修图中 |
成功后,照片预览页至少出现“原图 / 精修后”切换;本次选择了氛围感时增加“氛围感”,存在封面结果时增加“封面”。各结果互相独立,不把两个效果合成同一张图;有修图结果时还可以点击“查看高清图”。再次修图时,旧结果在新任务处理期间继续保留,新任务成功后整组替换为本次最新组合:本次未选择氛围感时会清除旧氛围感结果;新任务失败时保留旧成功图片,但状态优先显示“修图失败”,提醒用户本次任务未成功。
选择“还原为原图”会同时清除修图结果、失败状态和封面类型,缩略图恢复为“已上传”。删除照片时同步清理该照片的本地修图记录,避免已删除照片的结果残留。
### 状态流转图
![AI 修图任务状态流转](./assets/travel-album-ai/04-ai-task-state-flow.svg)
### 本功能验收
- [ ] 修图中不能重复点击 AI修图。
- [ ] 离开预览页后任务仍能结束并刷新缩略图。
- [ ] 成功后可切换查看原图、精修后以及本次实际生成的氛围感或封面。
- [ ] 同选原图精修和氛围感时两个结果均可独立查看。
- [ ] 再次修图成功后以本次结果组合覆盖旧结果。
- [ ] 失败后按钮恢复并允许重试。
- [ ] 还原原图后所有本地修图类型和结果被清除。
## 6. 选中 4 张及以上时的封面模板选择
### 功能说明
从相册管理多选照片并点击“AI修图”时,系统根据选中照片数量决定是否展示“封面风格模板”:
| 选中数量 | 弹窗表现 |
| --- | --- |
| 1~3 张 | 展示“原图精修 + 氛围感修图(选填)”两个模块,不展示封面模板 |
| ≥ 4 张 | 展示“原图精修 + 氛围感修图(选填)+ 封面模板”三个模块,并提高弹窗高度 |
封面模板为横向滚动单选,默认选中“旅行纪念册”。每个模板效果图右下角提供“预览”按钮,点击后全屏查看完整排版,不改变当前模板选择。当前提供:
- 旅行纪念册
- 简约留白
- 景区故事
- 电影胶片
每个模板展示名称和以第一张选中照片生成的排版效果图。用户确认后,所有选中照片先生成原图精修结果;选择了氛围感时,所有照片再各自生成一个氛围感结果;第一张选中照片另外生成独立封面并作为封面承载图。封面不会替代精修或氛围感结果,用户进入预览后可分别切换查看。
当前所有封面模板 `quotaCost = 0`,弹窗显示“赠送 · 不占精修张数”,不会减少剩余修图额度。后续收费时保留同一选择流程,由服务端为模板返回大于 0 的 `quotaCost`,客户端在确认前显示实际消耗,并以服务端成功结果为准扣减;收费能力目前尚未实现。
再次对不少于 4 张照片选择新封面模板并修图时,新封面效果覆盖旧封面效果。封面承载图处理失败时不标记为封面成功,缩略图显示“修图失败”并允许重试。选择“还原为原图”时清除封面效果和封面类型。
### 状态流转图
![多图封面模板选择流程](./assets/travel-album-ai/05-cover-template-flow.svg)
### 本功能验收
- [ ] 选中 3 张时不显示封面模板。
- [ ] 选中 4 张或更多时显示封面模板。
- [ ] 有封面模块时,弹窗同时完整显示原图精修、氛围感和封面三个风格模块。
- [ ] 封面模板可横向滚动且始终单选。
- [ ] 每个封面模板均可独立预览,关闭预览后保留原选择。
- [ ] 默认选中旅行纪念册。
- [ ] 当前显示赠送并且不消耗精修额度。
- [ ] 成功后第一张选中照片成为封面承载图。
- [ ] 再次生成封面时以新结果覆盖旧结果。
## 7. 相册缩略图结果类型与显示优先级
### 功能说明
相册管理缩略图左上角复用同一状态位,根据照片当前结果显示不同类型,让用户无需进入预览即可区分未修、处理中、已修、封面和失败照片。
| 缩略图状态 | 角标文案与颜色 | 缩略图内容 | 进入条件 | 可用操作 |
| --- | --- | --- | --- | --- |
| 普通原图 | 绿色“已上传” | 服务端原图或缩略图 | 没有本地修图结果 | 预览、选择、AI修图、删除 |
| 处理中 | 蓝色“修图中” | 有旧成功结果时继续显示旧结果,否则显示原图 | AI 任务未结束 | 预览、删除;不能再次修图 |
| 普通修图结果 | 紫色“AI已修” | 最新精修图片 | 原图精修成功,可同时存在氛围感结果 | 预览原图/精修后/氛围感、再次修图、还原 |
| 封面结果 | 橙色“AI封面” | 所选封面模板排版图 | 封面承载图生成成功 | 预览、再次修图、重新生成封面、还原 |
| 修图失败 | 红色“修图失败” | 有旧成功结果时保留旧图,否则显示原图 | 最近一次任务失败 | 再次修图、删除 |
状态显示优先级为:
`修图中 > 修图失败 > AI封面 > AI已修 > 已上传`
该优先级保证处理中或失败状态不会被历史成功结果遮盖。普通修图成功后显示“AI已修”;封面模板成功后,第一张选中照片显示封面排版图和“AI封面”,其他照片继续显示“AI已修”。再次修图成功后缩略图切换为最新结果;还原原图后回到“已上传”;删除照片后对应本地状态一起删除。
当前状态和结果由内存数据源在相册管理与照片预览之间共享,尚不支持 App 重启后的恢复。服务端接入时至少需要返回素材 ID、任务状态、结果类型、结果图地址、原图精修预设 ID、是否生成氛围感、封面模板 ID、失败原因和额度消耗,客户端才能恢复完全一致的缩略图状态。
### 状态流转图
![相册缩略图结果状态](./assets/travel-album-ai/06-thumbnail-state-flow.svg)
### 本功能验收
- [ ] 未修图照片显示“已上传”。
- [ ] 修图中照片显示“修图中”且不能重复提交。
- [ ] 普通成功结果显示“AI已修”和最新效果图。
- [ ] 封面成功结果显示“AI封面”和封面排版图。
- [ ] 最近一次任务失败时优先显示“修图失败”。
- [ ] 还原、覆盖和删除后缩略图状态同步更新。
+155 -4
View File
@@ -36,6 +36,8 @@
93FF17C52FFB84950033C304 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; 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; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -111,6 +113,8 @@
1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */, 1060D5E389506965D83EA1CC /* Pods-suixinkan.release.xcconfig */,
83445E7B4CCCF42C6FD481D3 /* Pods-suixinkanTests.debug.xcconfig */, 83445E7B4CCCF42C6FD481D3 /* Pods-suixinkanTests.debug.xcconfig */,
6CB3E1DA4E97AA4961B25733 /* Pods-suixinkanTests.release.xcconfig */, 6CB3E1DA4E97AA4961B25733 /* Pods-suixinkanTests.release.xcconfig */,
ECE6D74459339B5075CF14AD /* Pods-suixinkan.simulatordebug.xcconfig */,
DC152A95F1705B826BE8A56A /* Pods-suixinkanTests.simulatordebug.xcconfig */,
); );
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -432,7 +436,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010402; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -456,7 +460,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.4; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -498,7 +502,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010402; CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@@ -522,7 +526,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.4; MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -728,6 +732,150 @@
}; };
name = Debug; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -735,6 +883,7 @@
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
93FF17DB2FFB84950033C304 /* Debug */, 93FF17DB2FFB84950033C304 /* Debug */,
D1A2B3C52FFB84950033C402 /* SimulatorDebug */,
93FF17DC2FFB84950033C304 /* Release */, 93FF17DC2FFB84950033C304 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
@@ -744,6 +893,7 @@
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
93FF17D92FFB84950033C304 /* Debug */, 93FF17D92FFB84950033C304 /* Debug */,
D1A2B3C42FFB84950033C401 /* SimulatorDebug */,
93FF17DA2FFB84950033C304 /* Release */, 93FF17DA2FFB84950033C304 /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
@@ -753,6 +903,7 @@
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
A1B2C3DC2FFB84950033C305 /* Debug */, A1B2C3DC2FFB84950033C305 /* Debug */,
D1A2B3C62FFB84950033C403 /* SimulatorDebug */,
A1B2C3DA2FFB84950033C305 /* Release */, A1B2C3DA2FFB84950033C305 /* Release */,
); );
defaultConfigurationIsVisible = 0; 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
}
}
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "payment_method_alipay.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -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

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "payment_method_cash.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -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

@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "payment_method_wechat.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -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,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "payment_nalati_background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "payment_nalati_logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "travel_album_preview_ai_edit.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFFFFF" d="M764 824q-40 40 -97 40t-97 -40l-544 -542v-320q0 -25 17.5 -43t42.5 -18h340l528 538q41 40 41 97t-41 97zM338 -29h-179q-26 0 -44.5 18.5t-18.5 45.5v196q12 -3 31 -9t65 -29.5t77 -53.5q29 -27 63.5 -86t38.5 -82h-33zM885 463l-435 -438q-73 119 -124 171q-15 15 -39.5 32t-48 31t-45.5 26t-35 19l-13 6l448 446q24 24 60.5 24t60.5 -25l171 -171q25 -26 25 -61.5t-25 -59.5zM611 315l-43 43l32 31l42 -42zM692 397l-42 42l126 127l43 -43z"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "travel_album_preview_delete.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFFFFF" d="M368 440v-279q0 -13 8.5 -22t20.5 -9t20 8q9 10 9 23v279q0 13 -8.5 22.5t-20.5 9.5t-20.5 -9.5t-8.5 -22.5zM569 440v-279q0 -13 8.5 -22t20.5 -9t20.5 9t8.5 22v279q0 13 -8.5 22.5t-20.5 9.5t-20.5 -9.5t-8.5 -22.5zM283 -109h442q57 0 97 42t40 102v606h95q12 0 20.5 9.5t8.5 22.5t-8.5 22t-20.5 9h-259l-1 25q0 53 -35 101t-81 48h-154q-45 0 -80 -48t-36 -101v-25h-251q-12 0 -20.5 -9t-8.5 -22t8.5 -22.5t20.5 -9.5h87v-606q0 -60 40 -102t96 -42zM369 730q1 27 18.5 55t37.5 30h2h154q21 -2 39 -30t19 -55l1 -26h-272zM205 641h599v-606q0 -34 -23 -57.5t-56 -23.5h-442q-33 0 -55.5 23.5t-22.5 57.5v606v0z"/>
</svg>

After

Width:  |  Height:  |  Size: 700 B

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "travel_album_preview_refresh.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 -128 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFFFFF" d="M505.5 -127q-102.5 0 -196.5 39q-90 39 -159.5 108.5t-108.5 159.5q-39 94 -39 196.5t39 195.5q39 90 108.5 160t159.5 108q94 40 196.5 40t195.5 -40q90 -38 160 -108t108 -160q40 -93 40 -195.5t-40 -196.5q-38 -90 -108 -159.5t-160 -108.5q-93 -39 -195.5 -39zM505 819q-90 0 -172 -35q-80 -33 -141 -94.5t-95 -140.5q-35 -83 -35 -173t35 -172q34 -80 95 -141t141 -95q82 -35 172 -35t173 35q79 34 140.5 95t94.5 141q35 82 35 172t-35 173q-33 79 -94.5 140.5t-140.5 94.5q-83 35 -173 35zM595 472h-240l62 62q9 9 9 21.5t-9 21.5t-21.5 9t-21.5 -9l-113 -114q-9 -9 -9 -21.5t9 -21.5l113 -113q9 -9 21.5 -9t21.5 9t9 21.5t-9 21.5l-62 61h240q41 0 69.5 -28t28.5 -68t-28.5 -68.5t-69.5 -28.5h-221q-12 0 -21 -9t-9 -21.5t9 -21.5t21 -9h221q66 0 112 46.5t46 111.5t-46 111t-112 46z"/>
</svg>

After

Width:  |  Height:  |  Size: 863 B

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "purchased_alpine_lake.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "purchased_forest_waterfall.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "purchased_grassland_path.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "purchased_lakeside_flowers.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+46 -90
View File
@@ -2,129 +2,85 @@ import Foundation
/// 推送点击后可到达的业务页面。 /// 推送点击后可到达的业务页面。
enum PushDestination: Sendable, Equatable { enum PushDestination: Sendable, Equatable {
case payment case paymentRecord
case orders case paymentDetails
case verificationOrders
case task
case queue
case messageCenter case messageCenter
/// 目标页面是否依赖当前景区上下文。
var requiresScenicContext: Bool {
switch self {
case .payment, .task, .queue:
true
case .orders, .verificationOrders, .messageCenter:
false
}
}
} }
/// 极光/APNs payload 解析结果,兼容顶层字段及常见 JSON 嵌套格式。 /// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
struct PushPayload: Sendable, Equatable { struct PushPayload: Sendable, Equatable {
private let values: [String: String] private let type: String
/// 从系统通知 userInfo 解析路由字段。 /// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
nonisolated init(userInfo: [AnyHashable: Any]) { nonisolated init(userInfo: [AnyHashable: Any]) {
var flattened: [String: String] = [:] type = Self.extractType(from: userInfo)
Self.mergeDictionary(userInfo, into: &flattened)
values = flattened
} }
/// 从可发送的字符串字典创建 payload,供跨 Actor 回调及测试使用。 /// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
nonisolated init(values: [String: String]) { nonisolated init(values: [String: String]) {
self.values = values type = Self.normalizedType(values["type"])
} }
/// 可发送的规范化字段快照。 /// 可发送的规范化字段快照。
nonisolated var normalizedValues: [String: String] { nonisolated var normalizedValues: [String: String] {
values type.isEmpty ? [:] : ["type": type]
} }
/// 根据后端约定和历史兼容字段解析目标页面。 /// 仅根据后端业务消息类型解析目标页面。
nonisolated var destination: PushDestination { nonisolated var destination: PushDestination {
let type = value(for: "type") switch type {
let route = value(for: "route") case "1":
let uri = value(for: "uri") return .paymentRecord
let action = value(for: "action") case "6":
let merged = [route, uri, action].joined(separator: " ").lowercased() return .paymentDetails
default:
if type == "1" || type == "6"
|| merged.contains("payment") || merged.contains("payment_qr")
|| merged.contains("收款") {
return .payment
}
if merged.contains("queue") || merged.contains("scenic-queue")
|| merged.contains("排队") || merged.contains("叫号") {
return .queue
}
if merged.contains("verification") || merged.contains("writeoff")
|| merged.contains("write_off") || merged.contains("核销") {
return .verificationOrders
}
if merged.contains("order") || merged.contains("订单") {
return .orders
}
if merged.contains("task") || merged.contains("任务") {
return .task
}
return .messageCenter return .messageCenter
} }
private nonisolated func value(for key: String) -> String {
values[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
} }
private nonisolated static func mergeDictionary( private nonisolated static func extractType(
_ dictionary: [AnyHashable: Any], from dictionary: [AnyHashable: Any],
into result: inout [String: String] depth: Int = 0
) { ) -> String {
for (rawKey, value) in dictionary { guard depth <= 4 else { return "" }
guard let key = rawKey as? String else { continue } let directType = normalizedType(dictionary["type"])
merge(value, key: key, into: &result) guard directType.isEmpty else { return directType }
}
for key in nestedTypeContainerKeys {
guard let value = dictionary[key] else { continue }
if let nested = value as? [AnyHashable: Any] {
let nestedType = extractType(from: nested, depth: depth + 1)
if !nestedType.isEmpty { return nestedType }
continue
} }
private nonisolated static func merge( guard let text = value as? String,
_ value: Any,
key: String,
into result: inout [String: String]
) {
if let dictionary = value as? [String: Any] {
result[key] = stringValue(value)
mergeDictionary(dictionary, into: &result)
return
}
if let dictionary = value as? [AnyHashable: Any] {
result[key] = stringValue(value)
mergeDictionary(dictionary, into: &result)
return
}
let text = stringValue(value)
result[key] = text
guard Self.nestedJSONKeys.contains(key),
let data = text.data(using: .utf8), let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) let object = try? JSONSerialization.jsonObject(with: data),
else { return } let nested = object as? [String: Any]
else { continue }
if let dictionary = object as? [String: Any] { let bridged = Dictionary(uniqueKeysWithValues: nested.map {
mergeDictionary(dictionary, into: &result) (AnyHashable($0.key), $0.value)
})
let nestedType = extractType(from: bridged, depth: depth + 1)
if !nestedType.isEmpty { return nestedType }
} }
return ""
} }
private nonisolated static func stringValue(_ value: Any) -> String { private nonisolated static func normalizedType(_ value: Any?) -> String {
switch value { switch value {
case let string as String: case let string as String:
string return string.trimmingCharacters(in: .whitespacesAndNewlines)
case let number as NSNumber: case let number as NSNumber:
number.stringValue return number.stringValue
default: default:
String(describing: value) return ""
} }
} }
private nonisolated static let nestedJSONKeys: Set<String> = [ private nonisolated static let nestedTypeContainerKeys = [
"extras", "extra", "data", "JMessageExtra", "n_extras", "extras", "extra", "JMessageExtra", "n_extras",
] ]
} }
@@ -229,6 +229,7 @@ final class PushNotificationManager: NSObject {
/// 处理 Scene 冷启动携带的系统通知响应,并向极光上报点击。 /// 处理 Scene 冷启动携带的系统通知响应,并向极光上报点击。
func handleNotificationResponse(_ response: UNNotificationResponse) { func handleNotificationResponse(_ response: UNNotificationResponse) {
let userInfo = response.notification.request.content.userInfo let userInfo = response.notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "tap(cold-start)")
JPUSHService.handleRemoteNotification(userInfo) JPUSHService.handleRemoteNotification(userInfo)
handleNotificationTap( handleNotificationTap(
payload: PushPayload(userInfo: userInfo), payload: PushPayload(userInfo: userInfo),
@@ -238,10 +239,35 @@ final class PushNotificationManager: NSObject {
/// 处理后台静默通知并向极光上报到达,不触发页面跳转。 /// 处理后台静默通知并向极光上报到达,不触发页面跳转。
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) { func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
Self.logReceivedPush(userInfo, source: "background")
JPUSHService.handleRemoteNotification(userInfo) JPUSHService.handleRemoteNotification(userInfo)
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil) NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
} }
/// 将收到的推送 payload 打印到控制台,便于联调排查。
private nonisolated static func logReceivedPush(
_ userInfo: [AnyHashable: Any],
source: String
) {
#if DEBUG
let payloadText: String
let normalized = Dictionary(uniqueKeysWithValues: userInfo.compactMap { key, value in
(key as? String).map { ($0, value) }
})
if JSONSerialization.isValidJSONObject(normalized),
let data = try? JSONSerialization.data(
withJSONObject: normalized,
options: [.prettyPrinted, .sortedKeys]
),
let text = String(data: data, encoding: .utf8) {
payloadText = text
} else {
payloadText = String(describing: userInfo)
}
print("[Push] received (\(source)):\n\(payloadText)")
#endif
}
private func observeJPushNetworkLogin() { private func observeJPushNetworkLogin() {
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
@@ -352,7 +378,9 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
willPresent notification: UNNotification, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (Int) -> Void withCompletionHandler completionHandler: @escaping (Int) -> Void
) { ) {
JPUSHService.handleRemoteNotification(notification.request.content.userInfo) let userInfo = notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "foreground")
JPUSHService.handleRemoteNotification(userInfo)
Task { @MainActor in Task { @MainActor in
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil) NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
} }
@@ -367,6 +395,7 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
withCompletionHandler completionHandler: @escaping () -> Void withCompletionHandler completionHandler: @escaping () -> Void
) { ) {
let userInfo = response.notification.request.content.userInfo let userInfo = response.notification.request.content.userInfo
Self.logReceivedPush(userInfo, source: "tap")
JPUSHService.handleRemoteNotification(userInfo) JPUSHService.handleRemoteNotification(userInfo)
let payload = PushPayload(userInfo: userInfo) let payload = PushPayload(userInfo: userInfo)
let requestIdentifier = response.notification.request.identifier let requestIdentifier = response.notification.request.identifier
@@ -84,16 +84,8 @@ final class PushRouteCoordinator: PushRouting {
return return
} }
let destination: PushDestination
if route.destination.requiresScenicContext,
appStore.session.currentScenicId <= 0 {
destination = .messageCenter
} else {
destination = route.destination
}
mainTabController.dismiss(animated: false) mainTabController.dismiss(animated: false)
mainTabController.openPushDestination(destination) mainTabController.openPushDestination(route.destination)
} }
private func markHandled(_ identifier: String) { private func markHandled(_ identifier: String) {
@@ -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.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .scenicUser AppStore.shared.session.accountType = .scenicUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( 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.userName = firstNonEmpty(user.nickname, user.realName)
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
AppStore.shared.session.currentScenicName = user.scenicName AppStore.shared.session.currentScenicName = user.scenicName
@@ -90,10 +90,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId) AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .storeUser AppStore.shared.session.accountType = .storeUser
AppStore.shared.session.accountDisplayName = firstNonEmpty( 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.userName = user.realName
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username) AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone AppStore.shared.session.phone = user.phone
AppStore.shared.session.avatar = user.avatar AppStore.shared.session.avatar = user.avatar
AppStore.shared.session.currentScenicId = user.scenicId AppStore.shared.session.currentScenicId = user.scenicId
@@ -53,10 +53,11 @@ enum LoginValidationError: Equatable {
} }
} }
/// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。 /// 登录结果实体,区分正常登录、账号选择和注销冷静期恢复三种情况。
enum LoginResolution { enum LoginResolution {
case completed(V9AuthResponse, AccountSwitchAccount) case completed(V9AuthResponse, AccountSwitchAccount)
case needsAccountSelection(AccountSelectionPayload) case needsAccountSelection(AccountSelectionPayload)
case accountDeletionPending(V9AuthResponse, AccountDeletionRequest)
} }
/// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。 /// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。
@@ -196,7 +197,6 @@ struct V9ScenicUser: Decodable, Equatable {
let userId: Int let userId: Int
let scenicUserId: Int let scenicUserId: Int
let ssUserId: Int let ssUserId: Int
let username: String
let realName: String let realName: String
let nickname: String let nickname: String
let phone: String let phone: String
@@ -212,7 +212,7 @@ struct V9ScenicUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
@@ -238,8 +238,6 @@ struct V9ScenicUser: Decodable, Equatable {
case userId = "user_id" case userId = "user_id"
case scenicUserId = "scenic_user_id" case scenicUserId = "scenic_user_id"
case ssUserId = "ss_user_id" case ssUserId = "ss_user_id"
case username = "user_name"
case legacyUsername = "username"
case realName = "real_name" case realName = "real_name"
case nickname case nickname
case phone case phone
@@ -258,9 +256,6 @@ struct V9ScenicUser: Decodable, Equatable {
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0 scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 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) realName = try container.decodeLossyString(forKey: .realName)
nickname = try container.decodeLossyString(forKey: .nickname) nickname = try container.decodeLossyString(forKey: .nickname)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
@@ -281,8 +276,6 @@ struct V9StoreUser: Decodable, Equatable {
let id: Int let id: Int
let userId: Int let userId: Int
let storeUserId: Int let storeUserId: Int
let username: String
let userName: String
let realName: String let realName: String
let phone: String let phone: String
let avatar: String let avatar: String
@@ -300,14 +293,14 @@ struct V9StoreUser: Decodable, Equatable {
} }
var displayName: String { var displayName: String {
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
} }
func toAccountSwitchAccount() -> AccountSwitchAccount { func toAccountSwitchAccount() -> AccountSwitchAccount {
AccountSwitchAccount( AccountSwitchAccount(
accountType: accountType.nonEmpty ?? Self.accountTypeValue, accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId, businessUserId: businessUserId,
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号", title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
subtitle: joinAccountSubtitle(scenicName, roleName), subtitle: joinAccountSubtitle(scenicName, roleName),
phone: phone, phone: phone,
realName: realName, realName: realName,
@@ -325,8 +318,6 @@ struct V9StoreUser: Decodable, Equatable {
case id case id
case userId = "user_id" case userId = "user_id"
case storeUserId = "store_user_id" case storeUserId = "store_user_id"
case username
case userName = "user_name"
case realName = "real_name" case realName = "real_name"
case phone case phone
case avatar case avatar
@@ -346,8 +337,6 @@ struct V9StoreUser: Decodable, Equatable {
id = try container.decodeLossyInt(forKey: .id) ?? 0 id = try container.decodeLossyInt(forKey: .id) ?? 0
userId = try container.decodeLossyInt(forKey: .userId) ?? 0 userId = try container.decodeLossyInt(forKey: .userId) ?? 0
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 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) realName = try container.decodeLossyString(forKey: .realName)
phone = try container.decodeLossyString(forKey: .phone) phone = try container.decodeLossyString(forKey: .phone)
avatar = try container.decodeLossyString(forKey: .avatar) 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?()
}
}
@@ -9,10 +9,40 @@ import Foundation
struct RolePermissionResponse: Codable, Equatable, Sendable { struct RolePermissionResponse: Codable, Equatable, Sendable {
let role: RoleInfo let role: RoleInfo
let scenic: [ScenicInfo] let scenic: [ScenicInfo]
let store: [RolePermissionStoreInfo]
init(role: RoleInfo, scenic: [ScenicInfo] = []) { enum CodingKeys: String, CodingKey {
case role
case scenic
case store
}
init(
role: RoleInfo,
scenic: [ScenicInfo] = [],
store: [RolePermissionStoreInfo] = []
) {
self.role = role self.role = role
self.scenic = scenic self.scenic = scenic
self.store = store
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
role = try container.decode(RoleInfo.self, forKey: .role)
scenic = try container.decodeIfPresent([ScenicInfo].self, forKey: .scenic) ?? []
store = try container.decodeIfPresent([RolePermissionStoreInfo].self, forKey: .store) ?? []
}
}
/// 当前角色账号可使用的店铺摘要,来源于 `role-permission` 的 `store` 字段。
struct RolePermissionStoreInfo: Codable, Equatable, Sendable {
let id: Int
let name: String
init(id: Int = 0, name: String = "") {
self.id = id
self.name = name
} }
} }
@@ -16,21 +16,24 @@ enum HomeMenuIconFactory {
private static let imageCache = NSCache<NSString, UIImage>() private static let imageCache = NSCache<NSString, UIImage>()
/// 优先加载 Assets 资源图;否则加载 SF Symbol,并统一渲染到固定画布。 /// 优先加载 Assets 资源图并归一化画布;SF Symbol 保留系统符号表示与分层渲染。
static func image(named iconName: String) -> UIImage? { static func image(named iconName: String) -> UIImage? {
let cacheKey = iconName as NSString let cacheKey = iconName as NSString
if let cachedImage = imageCache.object(forKey: cacheKey) { if let cachedImage = imageCache.object(forKey: cacheKey) {
return cachedImage return cachedImage
} }
guard let sourceImage = UIImage(named: iconName) let image: UIImage?
?? UIImage(systemName: iconName, withConfiguration: symbolConfiguration) else { if let assetImage = UIImage(named: iconName) {
return nil image = renderOnFixedCanvas(assetImage)
} else {
image = UIImage(systemName: iconName, withConfiguration: symbolConfiguration)
} }
let normalizedImage = renderOnFixedCanvas(sourceImage) if let image {
imageCache.setObject(normalizedImage, forKey: cacheKey) imageCache.setObject(image, forKey: cacheKey)
return normalizedImage }
return image
} }
private static func renderOnFixedCanvas(_ image: UIImage) -> UIImage { private static func renderOnFixedCanvas(_ image: UIImage) -> UIImage {
@@ -20,6 +20,7 @@ enum AMapBootstrap {
defer { lock.unlock() } defer { lock.unlock() }
guard !isConfigured else { return true } guard !isConfigured else { return true }
#if !targetEnvironment(simulator)
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain) AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapLocationManager.updatePrivacyAgree(.didAgree) AMapLocationManager.updatePrivacyAgree(.didAgree)
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain) MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
@@ -29,6 +30,7 @@ enum AMapBootstrap {
AMapServices.shared().apiKey = AMapConfig.apiKey AMapServices.shared().apiKey = AMapConfig.apiKey
AMapServices.shared().enableHTTPS = true AMapServices.shared().enableHTTPS = true
#endif
isConfigured = true isConfigured = true
return true return true
} }
@@ -3,16 +3,28 @@
// suixinkan // suixinkan
// //
import CoreLocation
import Foundation import Foundation
/// 高德逆地理编码封装,对齐 Android `GeocodeSearch`。 /// 地址逆地理编码能力,供平台定位实现注入。
@MainActor @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() nonisolated static let shared = LocationGeocoder()
#if targetEnvironment(simulator)
private let geocoder = CLGeocoder()
#else
private var searchAPI: AMapSearchAPI? private var searchAPI: AMapSearchAPI?
private var continuation: CheckedContinuation<String, Never>? private var continuation: CheckedContinuation<String, Never>?
#endif
nonisolated private override init() { nonisolated private override init() {
super.init() super.init()
@@ -20,6 +32,26 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
/// 将坐标解析为可读地址。 /// 将坐标解析为可读地址。
func reverseGeocode(latitude: Double, longitude: Double) async -> String { 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 { do {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
} catch { } catch {
@@ -40,8 +72,10 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
request.radius = 200 request.radius = 200
api.aMapReGoecodeSearch(request) api.aMapReGoecodeSearch(request)
} }
#endif
} }
#if !targetEnvironment(simulator)
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) { func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
let address = response?.regeocode?.formattedAddress ?? "" let address = response?.regeocode?.formattedAddress ?? ""
continuation?.resume(returning: address) continuation?.resume(returning: address)
@@ -62,4 +96,9 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
searchAPI = api searchAPI = api
return api return api
} }
#endif
} }
#if !targetEnvironment(simulator)
extension LocationGeocoder: AMapSearchDelegate {}
#endif
@@ -6,7 +6,165 @@
import CoreLocation import CoreLocation
import Foundation 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 @MainActor
final class LocationProvider: NSObject, LocationProviding { final class LocationProvider: NSObject, LocationProviding {
@@ -19,7 +177,7 @@ final class LocationProvider: NSObject, LocationProviding {
super.init() super.init()
} }
@MainActor /// 请求坐标及高德逆地理编码地址。
func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot { func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
try await ensureLocationPermission() try await ensureLocationPermission()
@@ -51,7 +209,7 @@ final class LocationProvider: NSObject, LocationProviding {
} }
} }
@MainActor /// 请求一次当前位置坐标。
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D { func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
try AMapBootstrap.requireConfigured() try AMapBootstrap.requireConfigured()
try await ensureLocationPermission() try await ensureLocationPermission()
@@ -78,7 +236,7 @@ final class LocationProvider: NSObject, LocationProviding {
} }
} }
@MainActor /// 使用高德服务将坐标解析为地址。
func reverseGeocode(latitude: Double, longitude: Double) async -> String { func reverseGeocode(latitude: Double, longitude: Double) async -> String {
await geocoder.reverseGeocode(latitude: latitude, longitude: longitude) await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
} }
@@ -110,3 +268,5 @@ final class LocationProvider: NSObject, LocationProviding {
return manager return manager
} }
} }
#endif
@@ -16,20 +16,25 @@ struct HomeLocationSnapshot: Sendable, Equatable {
/// 定位服务协议,便于 ViewModel 注入与单元测试 mock。 /// 定位服务协议,便于 ViewModel 注入与单元测试 mock。
protocol LocationProviding: Sendable { protocol LocationProviding: Sendable {
/// 按指定精度请求当前坐标与地址。 /// 按指定精度请求当前坐标与地址。
@MainActor
func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot
/// 按指定精度请求当前坐标。 /// 按指定精度请求当前坐标。
@MainActor
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D
/// 逆地理编码。 /// 逆地理编码。
@MainActor
func reverseGeocode(latitude: Double, longitude: Double) async -> String func reverseGeocode(latitude: Double, longitude: Double) async -> String
} }
extension LocationProviding { extension LocationProviding {
/// 请求当前坐标与地址,默认使用最高精度以保持既有业务行为。 /// 请求当前坐标与地址,默认使用最高精度以保持既有业务行为。
@MainActor
func requestSnapshot() async throws -> HomeLocationSnapshot { func requestSnapshot() async throws -> HomeLocationSnapshot {
try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest) try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest)
} }
/// 请求当前坐标,默认使用最高精度以保持既有业务行为。 /// 请求当前坐标,默认使用最高精度以保持既有业务行为。
@MainActor
func requestCoordinate() async throws -> CLLocationCoordinate2D { func requestCoordinate() async throws -> CLLocationCoordinate2D {
try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest) 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 { func shootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
try await client.send( try await client.send(
APIRequest( 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 { struct ReferralOrderListResponse: Decodable {
let total: Int let total: Int
let list: [ReferralOrderEntity] let list: [ReferralOrderEntity]
@@ -3,6 +3,7 @@
// suixinkan // suixinkan
// //
import CoreLocation
import UIKit import UIKit
/// 主 Tab 中间扫码按钮结果处理,对齐 Android `MainTabViewModel.signIn`。 /// 主 Tab 中间扫码按钮结果处理,对齐 Android `MainTabViewModel.signIn`。
@@ -25,6 +25,17 @@ final class PaymentAPI {
) )
} }
/// 拉取当前景区收款页的背景、Logo 与标题配置。
func payPageConfig(scenicId: Int) async throws -> PayPageConfigResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/pay-page-config",
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
)
)
}
/// 拉取近 7 天收款记录。 /// 拉取近 7 天收款记录。
func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse { func collectionRecord(scenicId: Int) async throws -> RepaymentCollectionRecordResponse {
try await client.send( try await client.send(
@@ -5,6 +5,83 @@
import Foundation import Foundation
/// 收款页展示配置接口响应。
struct PayPageConfigResponse: Decodable, Sendable, Equatable {
let background: String
let logo: String
let title: String
let subtitle: String
init(background: String, logo: String, title: String, subtitle: String) {
self.background = background
self.logo = logo
self.title = title
self.subtitle = subtitle
}
}
/// 收款页最终可展示并可持久化的品牌配置。
struct PayPageConfig: Codable, Sendable, Equatable {
let background: String
let logo: String
let title: String
let subtitle: String
/// 那拉提景区随包默认配置。
static let nalatiDefault = PayPageConfig(
background: "",
logo: "",
title: "那拉提旅拍管理收费平台",
subtitle: "那拉提景区"
)
/// 将服务端非空有效字段合并到当前可用配置。
func merging(_ response: PayPageConfigResponse) -> PayPageConfig {
PayPageConfig(
background: Self.validRemoteImageURL(response.background) ?? background,
logo: Self.validRemoteImageURL(response.logo) ?? logo,
title: response.title.trimmedNonEmpty ?? title,
subtitle: response.subtitle.trimmedNonEmpty ?? subtitle
)
}
private static func validRemoteImageURL(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed),
url.scheme?.lowercased() == "https",
url.host != nil else {
return nil
}
return trimmed
}
}
/// 收款页品牌化景区判定策略。
enum PayPageBrandingPolicy {
/// 那拉提景区正式 ID。
static let nalatiScenicId = 128
/// 判断当前景区是否启用品牌收款页。
static func isNalati(scenicId: Int) -> Bool {
scenicId == nalatiScenicId
}
}
/// 收款页当前账号信息的展示文本格式化规则。
enum PaymentAccountDisplayFormatter {
/// 解析当前登录摄影师的真实姓名,空值显示占位符。
static func photographerName(realName: String) -> String {
realName.trimmedNonEmpty ?? "-"
}
/// 去除空值与重复项后,合并当前账号的全部店铺名称。
static func storeNames(_ names: [String]) -> String {
var seen = Set<String>()
let resolved = names.compactMap(\.trimmedNonEmpty).filter { seen.insert($0).inserted }
return resolved.isEmpty ? "-" : resolved.joined(separator: "、")
}
}
/// 摄影师收款码响应,包含静态与动态支付 URL。 /// 摄影师收款码响应,包含静态与动态支付 URL。
struct PayCodeResponse: Decodable, Sendable, Equatable { struct PayCodeResponse: Decodable, Sendable, Equatable {
let staticPayURL: String let staticPayURL: String
@@ -101,3 +178,10 @@ struct PaymentMerchantInfo: Sendable, Equatable {
company: "扬州元智享网络科技有限公司" company: "扬州元智享网络科技有限公司"
) )
} }
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
@@ -0,0 +1,51 @@
//
// PayPageConfigCache.swift
// suixinkan
//
import Foundation
/// 收款页品牌配置缓存协议,供 ViewModel 注入持久化实现或测试替身。
protocol PayPageConfigCaching {
/// 读取指定景区的品牌配置。
func config(scenicId: Int) -> PayPageConfig?
/// 保存指定景区的完整品牌配置。
func save(_ config: PayPageConfig, scenicId: Int)
}
/// 使用 UserDefaults 按接口环境与景区隔离的收款页品牌配置缓存。
final class PayPageConfigCache: PayPageConfigCaching {
private static let version = "v1"
private let defaults: UserDefaults
private let environmentNamespace: String
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
/// 使用指定持久化容器和 API 环境创建缓存。
init(
defaults: UserDefaults = .standard,
environment: APIEnvironment = .current
) {
self.defaults = defaults
environmentNamespace = environment.baseURL.host ?? environment.baseURL.absoluteString
}
func config(scenicId: Int) -> PayPageConfig? {
guard scenicId > 0,
let data = defaults.data(forKey: key(scenicId: scenicId)) else {
return nil
}
return try? decoder.decode(PayPageConfig.self, from: data)
}
func save(_ config: PayPageConfig, scenicId: Int) {
guard scenicId > 0, let data = try? encoder.encode(config) else { return }
defaults.set(data, forKey: key(scenicId: scenicId))
}
private func key(scenicId: Int) -> String {
"payment.pay_page_config.\(Self.version).\(environmentNamespace).\(scenicId)"
}
}
@@ -17,6 +17,9 @@ final class PaymentCollectionDetailsViewModel {
private(set) var showAmountDialog = false private(set) var showAmountDialog = false
private(set) var amount = "" private(set) var amount = ""
private(set) var remark = "" private(set) var remark = ""
private(set) var scenicId = 0
private(set) var payPageConfig = PayPageConfig.nalatiDefault
private(set) var brandingRefreshVersion = 0
let merchantInfo = PaymentMerchantInfo.defaultInfo let merchantInfo = PaymentMerchantInfo.defaultInfo
@@ -25,38 +28,77 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = "" private var staticPayURL = ""
private var dynamicPayURL = "" private var dynamicPayURL = ""
private var hasCompletedPayCodeRequest = false
private let appStore: AppStore private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching
init(appStore: AppStore = .shared) { init(
appStore: AppStore = .shared,
payPageConfigCache: PayPageConfigCaching = PayPageConfigCache()
) {
self.appStore = appStore self.appStore = appStore
self.payPageConfigCache = payPageConfigCache
scenicId = appStore.session.currentScenicId
scenicName = appStore.session.currentScenicName
staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
if PayPageBrandingPolicy.isNalati(scenicId: scenicId) {
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
}
} }
var hasPayCode: Bool { var hasPayCode: Bool {
qrImage != nil && !staticPayURL.isEmpty qrImage != nil && !staticPayURL.isEmpty
} }
/// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。
var isPayCodeLoading: Bool {
loading || !hasCompletedPayCodeRequest
}
var displayScenicName: String { var displayScenicName: String {
scenicName.isEmpty ? "暂无景区" : scenicName scenicName.isEmpty ? "暂无景区" : scenicName
} }
var usesNalatiBranding: Bool {
PayPageBrandingPolicy.isNalati(scenicId: scenicId)
}
var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName(
realName: appStore.session.realName
)
}
var displayStoreNames: String {
let names = appStore.permissions.rolePermissionList().flatMap(\.store).map(\.name)
return PaymentAccountDisplayFormatter.storeNames(names)
}
func refreshLocalState() { func refreshLocalState() {
scenicId = appStore.session.currentScenicId
scenicName = appStore.session.currentScenicName scenicName = appStore.session.currentScenicName
staffId = appStore.session.userId staffId = appStore.session.userId
isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice isVoiceBroadcastOpen = appStore.payment.isOpenReceiveVoice
if usesNalatiBranding {
payPageConfig = payPageConfigCache.config(scenicId: scenicId) ?? .nalatiDefault
} else {
payPageConfig = .nalatiDefault
}
notifyStateChange() notifyStateChange()
} }
func loadPayCode(api: PaymentAPI) async { func loadPayCode(api: PaymentAPI) async {
guard !loading else { return } guard !loading else { return }
refreshLocalState()
loading = true loading = true
notifyStateChange() refreshLocalState()
defer { defer {
loading = false loading = false
hasCompletedPayCodeRequest = true
notifyStateChange() notifyStateChange()
} }
let scenicId = appStore.session.currentScenicId let scenicId = self.scenicId
guard scenicId > 0 else { guard scenicId > 0 else {
clearPayCode() clearPayCode()
return return
@@ -72,8 +114,27 @@ final class PaymentCollectionDetailsViewModel {
} }
} }
func loadPayPageConfig(api: PaymentAPI) async {
let requestedScenicId = appStore.session.currentScenicId
guard PayPageBrandingPolicy.isNalati(scenicId: requestedScenicId) else { return }
do {
let response = try await api.payPageConfig(scenicId: requestedScenicId)
guard appStore.session.currentScenicId == requestedScenicId else { return }
let resolved = payPageConfig.merging(response)
payPageConfigCache.save(resolved, scenicId: requestedScenicId)
payPageConfig = resolved
brandingRefreshVersion += 1
notifyStateChange()
} catch {
// 品牌配置失败时保留缓存或随包默认值,不影响收款码与页面交互。
}
}
func refresh(api: PaymentAPI) async { func refresh(api: PaymentAPI) async {
await loadPayCode(api: api) async let payCode: Void = loadPayCode(api: api)
async let pageConfig: Void = loadPayPageConfig(api: api)
_ = await (payCode, pageConfig)
} }
func showSetAmountDialog() { func showSetAmountDialog() {
@@ -17,11 +17,11 @@ final class ProfileViewModel {
var onStateChange: (() -> Void)? var onStateChange: (() -> Void)?
var displayNickname: String { var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称" nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
} }
var displayRealName: String { var displayRealName: String {
nonEmpty(userInfo?.realName) ?? "--" nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
} }
var displayPhone: String { var displayPhone: String {
@@ -37,6 +37,17 @@ final class ProfileViewModel {
return uid.isEmpty ? "--" : uid 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 { var accountDisplayName: String {
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines) let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name return name.isEmpty ? "--" : name
@@ -112,6 +123,7 @@ final class ProfileViewModel {
if !info.roleName.isEmpty { if !info.roleName.isEmpty {
AppStore.shared.session.roleName = info.roleName AppStore.shared.session.roleName = info.roleName
} }
notifyStateChange()
if showPhotographerFields { if showPhotographerFields {
async let realName = api.realNameInfo() 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)"
}
}
@@ -116,6 +116,8 @@ struct TravelAlbumAvailableOrder: Decodable, Sendable, Equatable, Hashable {
/// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。 /// 旅拍相册素材实体,对齐 Android `TravelAlbumMaterialEntity`。
struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable { struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiable {
private static let bundledImageURLPrefix = "asset://"
let id: Int let id: Int
let userEquityTravelId: Int let userEquityTravelId: Int
let status: Int let status: Int
@@ -175,6 +177,18 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
/// 将工程内图片资源转换为素材可识别的本地地址,仅供无接口数据的演示素材使用。
static func bundledImageURL(assetName: String) -> String {
bundledImageURLPrefix + assetName
}
/// 返回本地演示素材对应的图片资源名;真实网络素材返回 `nil`。
var bundledImageAssetName: String? {
guard fileUrl.hasPrefix(Self.bundledImageURLPrefix) else { return nil }
let assetName = String(fileUrl.dropFirst(Self.bundledImageURLPrefix.count))
return assetName.isEmpty ? nil : assetName
}
} }
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。 /// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
@@ -0,0 +1,316 @@
//
// TravelAlbumPhotoPreviewModels.swift
// suixinkan
//
import Foundation
/// 单张照片的 AI 修图状态,控制处理中禁用、结果对比以及再次修图覆盖。
struct TravelAlbumAIEditResultState: Equatable, Sendable {
/// 相册缩略图需要展示的 AI 修图状态。
enum ThumbnailStatus: Equatable, Sendable {
case none
case processing
case edited
case cover
case failed
}
/// 当前预览展示的版本。
enum DisplayMode: Equatable, Sendable {
case original
case edited
case atmosphere
case cover
}
private(set) var isProcessing = false
private(set) var didFailLastAttempt = false
private(set) var appliedPresetID: String?
private(set) var appliedAtmosphereOptionID: String?
private(set) var appliedCoverTemplateID: String?
private(set) var displayMode: DisplayMode = .original
/// 已经存在可查看的修图结果。
var hasEditedResult: Bool {
appliedPresetID != nil
}
/// 处理期间禁止再次发起 AI 修图。
var canStartEditing: Bool {
!isProcessing
}
/// 当前结果是否为封面模板生成的封面图。
var isCover: Bool {
appliedCoverTemplateID != nil
}
/// 当前是否已经生成可独立查看的氛围感修图结果。
var hasAtmosphereResult: Bool {
appliedAtmosphereOptionID != nil
}
/// 根据修图任务状态生成缩略图角标,处理中和失败优先于历史成功结果。
var thumbnailStatus: ThumbnailStatus {
if isProcessing {
return .processing
}
if didFailLastAttempt {
return .failed
}
if isCover {
return .cover
}
return hasEditedResult ? .edited : .none
}
/// 开始修图;已有结果保留到新结果完成,避免处理中画面回退。
mutating func startProcessing() {
guard !isProcessing else { return }
isProcessing = true
didFailLastAttempt = false
}
/// 完成修图并覆盖旧预设结果,默认展示新的修图后版本。
mutating func complete(
presetID: String,
atmosphereOptionID: String? = nil,
coverTemplateID: String? = nil
) {
isProcessing = false
didFailLastAttempt = false
appliedPresetID = presetID
appliedAtmosphereOptionID = atmosphereOptionID
appliedCoverTemplateID = coverTemplateID
displayMode = coverTemplateID == nil ? .edited : .cover
}
/// 处理失败时恢复入口;已有成功结果继续保留。
mutating func failProcessing() {
isProcessing = false
didFailLastAttempt = true
}
/// 选择还原原图后清除历史修图结果和失败状态。
mutating func restoreOriginal() {
isProcessing = false
didFailLastAttempt = false
appliedPresetID = nil
appliedAtmosphereOptionID = nil
appliedCoverTemplateID = nil
displayMode = .original
}
/// 在已有结果时切换原图或修图后预览。
mutating func selectDisplayMode(_ mode: DisplayMode) {
guard hasEditedResult else {
displayMode = .original
return
}
if mode == .atmosphere, !hasAtmosphereResult {
displayMode = .edited
return
}
if mode == .cover, !isCover {
displayMode = .edited
return
}
displayMode = mode
}
}
/// 相册管理底部操作栏状态,用于在上传与多选操作之间切换。
struct TravelAlbumSelectionToolbarState: Equatable, Sendable {
let isSelectionMode: Bool
let selectedCount: Int
/// 普通状态显示上传照片。
var showsUpload: Bool {
!isSelectionMode
}
/// 多选状态显示 AI 修图和删除。
var showsSelectionActions: Bool {
isSelectionMode
}
/// 至少选中一张照片后才允许执行批量操作。
var selectionActionsEnabled: Bool {
isSelectionMode && selectedCount > 0
}
}
/// 相册照片预览状态,维护当前照片位置并处理左右切换。
struct TravelAlbumPhotoPreviewState: Equatable, Sendable {
private(set) var materials: [TravelAlbumMaterial]
private(set) var currentIndex: Int
init(materials: [TravelAlbumMaterial], initialIndex: Int) {
self.materials = materials
currentIndex = min(max(initialIndex, 0), max(materials.count - 1, 0))
}
/// 当前正在预览的照片。
var currentMaterial: TravelAlbumMaterial? {
materials.indices.contains(currentIndex) ? materials[currentIndex] : nil
}
/// 切换至下一张;已经是最后一张时保持不变。
@discardableResult
mutating func moveNext() -> Bool {
guard currentIndex + 1 < materials.count else { return false }
currentIndex += 1
return true
}
/// 切换至上一张;已经是第一张时保持不变。
@discardableResult
mutating func movePrevious() -> Bool {
guard currentIndex > 0 else { return false }
currentIndex -= 1
return true
}
/// 删除当前照片,并自动定位到相邻照片。
@discardableResult
mutating func removeCurrent() -> Bool {
guard materials.indices.contains(currentIndex) else { return false }
materials.remove(at: currentIndex)
if currentIndex >= materials.count {
currentIndex = max(materials.count - 1, 0)
}
return !materials.isEmpty
}
}
/// 相册照片 AI 修图预设,当前为本地演示数据,后续可替换为服务端下发的预设列表。
struct TravelAlbumEditPreset: Hashable, Sendable, Identifiable {
/// 图像效果类型,用于在预览页生成对应效果图。
enum Effect: String, Hashable, Sendable {
case original
case portrait
case vintage
case brocade
case distantMountain
case mist
case summer
case rich
}
let id: String
let title: String
let effect: Effect
/// AI 修图底部弹窗展示的默认预设,暂无接口时用于完整演示选择流程。
static let defaultOptions: [TravelAlbumEditPreset] = [
TravelAlbumEditPreset(id: "original", title: "还原为原图", effect: .original),
TravelAlbumEditPreset(id: "portrait", title: "写真-简约肖像", effect: .portrait),
TravelAlbumEditPreset(id: "vintage", title: "写真-清冷古风", effect: .vintage),
TravelAlbumEditPreset(id: "brocade", title: "旅拍-锦绣", effect: .brocade),
TravelAlbumEditPreset(id: "distant_mountain", title: "旅拍-远山", effect: .distantMountain),
TravelAlbumEditPreset(id: "mist", title: "旅拍-薄雾", effect: .mist),
TravelAlbumEditPreset(id: "summer", title: "油画-夏日", effect: .summer),
TravelAlbumEditPreset(id: "rich", title: "油画-浓郁", effect: .rich),
]
}
/// 可选的氛围感修图样式;选中后在原图精修之外额外生成一张独立结果。
struct TravelAlbumAtmosphereEditOption: Hashable, Sendable, Identifiable {
let id: String
let title: String
let subtitle: String
let effect: TravelAlbumEditPreset.Effect
/// 暂无接口时用于演示横向滚动单选的氛围感样式。
static let defaultOptions: [TravelAlbumAtmosphereEditOption] = [
TravelAlbumAtmosphereEditOption(
id: "warm_sun",
title: "日落暖阳",
subtitle: "温暖柔和",
effect: .summer
),
TravelAlbumAtmosphereEditOption(
id: "retro_film",
title: "复古胶片",
subtitle: "怀旧颗粒感",
effect: .vintage
),
TravelAlbumAtmosphereEditOption(
id: "clear_blue",
title: "清透蓝调",
subtitle: "通透轻盈",
effect: .mist
),
TravelAlbumAtmosphereEditOption(
id: "forest_mist",
title: "森系薄雾",
subtitle: "低饱和自然感",
effect: .distantMountain
),
TravelAlbumAtmosphereEditOption(
id: "vivid_story",
title: "浓郁故事",
subtitle: "高饱和电影感",
effect: .rich
),
]
/// 兼容现有结果测试与默认演示数据的首个氛围感样式。
static let defaultOption = defaultOptions[0]
}
/// 多图 AI 修图使用的封面风格模板,当前为本地演示数据,后续可替换为服务端配置。
struct TravelAlbumCoverTemplate: Hashable, Sendable, Identifiable {
/// 封面预览的排版样式,用于生成可辨识的本地效果图。
enum PreviewStyle: String, Hashable, Sendable {
case travelMemoir
case minimal
case scenicStory
case film
}
/// 触发展示封面模板选择的最少照片数量。
static let minimumPhotoCount = 4
let id: String
let title: String
let previewStyle: PreviewStyle
/// 当前消耗的精修额度;免费阶段为 0,后续收费可由服务端改为正数。
let quotaCost: Int
/// 当前模板是否为赠送且不占用精修张数。
var isFreeGift: Bool {
quotaCost == 0
}
/// 判断本次多选是否需要展示封面模板模块。
static func shouldShow(for selectedPhotoCount: Int) -> Bool {
selectedPhotoCount >= minimumPhotoCount
}
/// 暂无接口时用于完整演示单选流程的封面模板。
static let defaultOptions: [TravelAlbumCoverTemplate] = [
TravelAlbumCoverTemplate(id: "travel_memoir", title: "旅行纪念册", previewStyle: .travelMemoir, quotaCost: 0),
TravelAlbumCoverTemplate(id: "minimal", title: "简约留白", previewStyle: .minimal, quotaCost: 0),
TravelAlbumCoverTemplate(id: "scenic_story", title: "景区故事", previewStyle: .scenicStory, quotaCost: 0),
TravelAlbumCoverTemplate(id: "film", title: "电影胶片", previewStyle: .film, quotaCost: 0),
]
}
/// AI 修图弹窗最终提交的样式组合,统一承载原图精修、氛围感和封面模板。
struct TravelAlbumAIEditSelection: Equatable, Sendable {
let refinedPreset: TravelAlbumEditPreset
let atmosphereOption: TravelAlbumAtmosphereEditOption?
let coverTemplate: TravelAlbumCoverTemplate?
/// 每张普通照片需要生成的修图结果数量,不包含原图。
var resultCountPerPhoto: Int {
atmosphereOption == nil ? 1 : 2
}
/// 弹窗当前展示并提交的样式类别数量。
var selectedStyleCount: Int {
1 + (atmosphereOption == nil ? 0 : 1) + (coverTemplate == nil ? 0 : 1)
}
}
@@ -19,6 +19,8 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
var errorMessage: String? var errorMessage: String?
let localPath: String let localPath: String
var remoteUrl: String var remoteUrl: String
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
/// 是否未上传完成。 /// 是否未上传完成。
var isNotUploaded: Bool { var isNotUploaded: Bool {
@@ -52,6 +54,12 @@ struct TravelAlbumOTGPhotoSection: Hashable, Sendable {
let photos: [TravelAlbumOTGPhotoItem] let photos: [TravelAlbumOTGPhotoItem]
} }
/// 有线传输照片预览上下文,保存可预览照片顺序和点击照片位置。
struct TravelAlbumOTGPhotoPreviewContext: Equatable, Sendable {
let photos: [TravelAlbumOTGPhotoItem]
let initialIndex: Int
}
/// OTG 传输页 Tab。 /// OTG 传输页 Tab。
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable { enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
case all case all
@@ -80,6 +88,16 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
} }
} }
/// 模式选择弹窗中的业务说明。
var detailText: String {
switch self {
case .liveUpload:
return "相机拍摄后,照片自动传输并上传到当前相册"
case .postTransfer:
return "拍摄完成后,再选择照片批量传输"
}
}
/// 是否在新照片导入 App 后自动上传。 /// 是否在新照片导入 App 后自动上传。
var shouldAutoUploadNewImports: Bool { var shouldAutoUploadNewImports: Bool {
self == .liveUpload self == .liveUpload
@@ -113,6 +131,18 @@ extension TravelAlbumOTGPhotoItem {
} }
extension Array where Element == TravelAlbumOTGPhotoItem { extension Array where Element == TravelAlbumOTGPhotoItem {
/// 按当前列表顺序生成预览上下文,不可预览照片不会占用左右滑动页码。
func previewContext(startingWith photoID: String) -> TravelAlbumOTGPhotoPreviewContext? {
let previewablePhotos = filter { $0.thumbnailURL != nil }
guard let initialIndex = previewablePhotos.firstIndex(where: { $0.id == photoID }) else {
return nil
}
return TravelAlbumOTGPhotoPreviewContext(
photos: previewablePhotos,
initialIndex: initialIndex
)
}
/// 按 Android OTG 页规则构造半小时照片分段。 /// 按 Android OTG 页规则构造半小时照片分段。
func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] { func buildPhotoSections() -> [TravelAlbumOTGPhotoSection] {
let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in let pairs = compactMap { photo -> (slotStart: Date, photo: TravelAlbumOTGPhotoItem)? in
@@ -318,7 +348,8 @@ enum TravelAlbumOTGPhotoFormatMatcher {
extension TravelAlbumOTGPhotoRecord { extension TravelAlbumOTGPhotoRecord {
/// 转为页面展示项。 /// 转为页面展示项。
func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem { 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 } guard !path.isEmpty else { return false }
if path.hasPrefix("http") { return true } if path.hasPrefix("http") { return true }
return storage.fileExists(relativePath: path, albumId: albumId) return storage.fileExists(relativePath: path, albumId: albumId)
@@ -343,7 +374,9 @@ extension TravelAlbumOTGPhotoRecord {
progress: progress, progress: progress,
errorMessage: errorMessage, errorMessage: errorMessage,
localPath: localPath, localPath: localPath,
remoteUrl: remoteUrl remoteUrl: remoteUrl,
autoRetouchState: autoRetouchState,
autoRetouchTemplateId: autoRetouchTemplateId
) )
} }
} }
@@ -56,6 +56,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
let albumId: Int let albumId: Int
let userId: String let userId: String
var remoteUrl: String var remoteUrl: String
var materialId: Int?
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: String?
var retouchedPath: String
var updatedAt: Int64 var updatedAt: Int64
/// 创建 OTG 本地照片记录。 /// 创建 OTG 本地照片记录。
@@ -74,6 +78,10 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId: Int, albumId: Int,
userId: String, userId: String,
remoteUrl: String = "", remoteUrl: String = "",
materialId: Int? = nil,
autoRetouchState: TravelAlbumAutoRetouchState = .none,
autoRetouchTemplateId: String? = nil,
retouchedPath: String = "",
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000) updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
) { ) {
self.id = id self.id = id
@@ -90,12 +98,17 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.albumId = albumId self.albumId = albumId
self.userId = userId self.userId = userId
self.remoteUrl = remoteUrl self.remoteUrl = remoteUrl
self.materialId = materialId
self.autoRetouchState = autoRetouchState
self.autoRetouchTemplateId = autoRetouchTemplateId
self.retouchedPath = retouchedPath
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt 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 一次性迁移并回写。 /// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
@@ -115,16 +128,30 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId = try container.decode(Int.self, forKey: .albumId) albumId = try container.decode(Int.self, forKey: .albumId)
userId = try container.decode(String.self, forKey: .userId) userId = try container.decode(String.self, forKey: .userId)
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? "" 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 updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
} }
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { 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 var copy = self
copy.status = .pending if interruptedUpload {
copy.progress = 0 copy.status = copy.materialId == nil ? .pending : .uploaded
copy.progress = copy.materialId == nil ? 0 : 100
copy.errorMessage = nil copy.errorMessage = nil
}
if interruptedRetouch {
copy.autoRetouchState = copy.materialId == nil ? .none : .failed
}
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
return copy return copy
} }
@@ -242,7 +269,7 @@ final class TravelAlbumOTGPhotoStore {
?? TravelAlbumClientPhotoID.make() ?? TravelAlbumClientPhotoID.make()
return copy.normalizedAfterInterruptedTransfer() return copy.normalizedAfterInterruptedTransfer()
} }
if migrated.map(\.clientPhotoId) != scoped.map(\.clientPhotoId) { if migrated != scoped {
save(migrated, albumId: albumId) save(migrated, albumId: albumId)
} }
return migrated return migrated
@@ -333,6 +360,22 @@ final class TravelAlbumOTGPhotoStore {
return url 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 { func relativePath(for url: URL, albumId: Int) -> String {
let path = url.standardizedFileURL.path let path = url.standardizedFileURL.path
@@ -382,6 +425,9 @@ final class TravelAlbumOTGPhotoStore {
if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) { if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) {
resolved.thumbnailPath = thumbnailURL.path resolved.thumbnailPath = thumbnailURL.path
} }
if let retouchedURL = absoluteURL(for: record.retouchedPath, albumId: albumId) {
resolved.retouchedPath = retouchedURL.path
}
return resolved return resolved
} }
@@ -433,7 +479,7 @@ final class TravelAlbumOTGPhotoStore {
} }
private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) { 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 !path.isEmpty else { return }
guard let url = absoluteURL(for: path, albumId: record.albumId) else { return } guard let url = absoluteURL(for: path, albumId: record.albumId) else { return }
try? fileManager.removeItem(at: url) try? fileManager.removeItem(at: url)
@@ -463,6 +509,11 @@ final class TravelAlbumOTGPhotoStore {
? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId) ? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId)
: record.thumbnailPath : record.thumbnailPath
} }
if !record.retouchedPath.isEmpty {
normalized.retouchedPath = record.retouchedPath.hasPrefix("/")
? relativePath(for: URL(fileURLWithPath: record.retouchedPath), albumId: albumId)
: record.retouchedPath
}
return normalized return normalized
} }
@@ -0,0 +1,320 @@
//
// TravelAlbumAIEditResultStore.swift
// suixinkan
//
import CoreImage
import UIKit
/// AI 修图结果的内存数据源,在相册管理与照片预览之间共享本地演示结果。
@MainActor
final class TravelAlbumAIEditResultStore {
/// 单张照片的修图记录。
struct Record {
var state: TravelAlbumAIEditResultState
var editedImage: UIImage?
var atmosphereImage: UIImage?
var coverImage: UIImage?
}
static let shared = TravelAlbumAIEditResultStore()
static let didChangeNotification = Notification.Name("TravelAlbumAIEditResultStore.didChange")
static let materialIDUserInfoKey = "materialID"
private var recordsByMaterialID: [Int: Record] = [:]
private init() {}
/// 获取指定照片的修图状态。
func state(for materialID: Int) -> TravelAlbumAIEditResultState {
recordsByMaterialID[materialID]?.state ?? TravelAlbumAIEditResultState()
}
/// 获取指定照片最新的修图结果。
func editedImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.editedImage
}
/// 获取指定照片最新的氛围感修图结果。
func atmosphereImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.atmosphereImage
}
/// 获取指定照片最新的封面模板结果。
func coverImage(for materialID: Int) -> UIImage? {
recordsByMaterialID[materialID]?.coverImage
}
/// 将照片标记为修图中,已有结果继续保留到新结果完成。
func startProcessing(materialIDs: [Int]) {
materialIDs.forEach { materialID in
var record = recordsByMaterialID[materialID]
?? Record(
state: TravelAlbumAIEditResultState(),
editedImage: nil,
atmosphereImage: nil,
coverImage: nil
)
record.state.startProcessing()
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
}
/// 保存最新结果并覆盖同一照片之前的修图结果。
func complete(
materialID: Int,
presetID: String,
atmosphereOptionID: String? = nil,
coverTemplateID: String? = nil,
editedImage: UIImage,
atmosphereImage: UIImage? = nil,
coverImage: UIImage? = nil
) {
var record = recordsByMaterialID[materialID]
?? Record(
state: TravelAlbumAIEditResultState(),
editedImage: nil,
atmosphereImage: nil,
coverImage: nil
)
record.state.complete(
presetID: presetID,
atmosphereOptionID: atmosphereOptionID,
coverTemplateID: coverTemplateID
)
record.editedImage = editedImage
record.atmosphereImage = atmosphereImage
record.coverImage = coverImage
recordsByMaterialID[materialID] = record
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 }
record.state.failProcessing()
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 还原为原图时清除该照片的本地修图结果。
func restoreOriginal(materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.restoreOriginal()
record.editedImage = nil
record.atmosphereImage = nil
record.coverImage = nil
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 切换指定照片当前展示的原图或修图后版本。
func selectDisplayMode(_ mode: TravelAlbumAIEditResultState.DisplayMode, materialID: Int) {
guard var record = recordsByMaterialID[materialID] else { return }
record.state.selectDisplayMode(mode)
recordsByMaterialID[materialID] = record
notifyChange(materialID: materialID)
}
/// 删除照片时同步清理其本地修图结果。
func remove(materialID: Int) {
recordsByMaterialID.removeValue(forKey: materialID)
notifyChange(materialID: materialID)
}
private func notifyChange(materialID: Int) {
NotificationCenter.default.post(
name: Self.didChangeNotification,
object: self,
userInfo: [Self.materialIDUserInfoKey: materialID]
)
}
}
/// 本地 AI 修图效果生成器;后续接入服务端时可替换为结果图片下载。
@MainActor
enum TravelAlbumAIEditImageProcessor {
private static let context = CIContext()
/// 根据预设生成演示用修图图片。
static func render(
effect: TravelAlbumEditPreset.Effect,
source: UIImage
) -> UIImage {
guard effect != .original, let ciImage = CIImage(image: source) else { return source }
let output: CIImage
switch effect {
case .original:
output = ciImage
case .portrait:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.86,
kCIInputBrightnessKey: 0.07,
kCIInputContrastKey: 1.08,
])
case .vintage:
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.45])
case .brocade:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 1.22,
kCIInputBrightnessKey: 0.04,
kCIInputContrastKey: 1.1,
])
case .distantMountain:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.72,
kCIInputBrightnessKey: 0.1,
kCIInputContrastKey: 0.9,
])
case .mist:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 0.7,
kCIInputBrightnessKey: 0.14,
kCIInputContrastKey: 0.82,
])
case .summer:
output = ciImage.applyingFilter("CISepiaTone", parameters: [kCIInputIntensityKey: 0.24])
case .rich:
output = ciImage.applyingFilter("CIColorControls", parameters: [
kCIInputSaturationKey: 1.38,
kCIInputBrightnessKey: -0.02,
kCIInputContrastKey: 1.2,
])
}
guard let cgImage = context.createCGImage(output, from: output.extent) else { return source }
return UIImage(cgImage: cgImage, scale: source.scale, orientation: source.imageOrientation)
}
}
/// 本地封面模板效果生成器,让封面结果在缩略图和预览页中与普通照片明显区分。
@MainActor
enum TravelAlbumCoverTemplateImageProcessor {
/// 将照片渲染为指定封面排版;无原图时使用稳定的演示背景。
static func render(
template: TravelAlbumCoverTemplate,
source: UIImage?,
targetSize: CGSize = CGSize(width: 1200, height: 900)
) -> UIImage {
UIGraphicsImageRenderer(size: targetSize).image { context in
let bounds = CGRect(origin: .zero, size: targetSize)
UIColor(hex: 0xCAD8E8).setFill()
context.cgContext.fill(bounds)
if let source {
source.draw(in: aspectFillRect(imageSize: source.size, bounds: bounds))
}
let unit = min(targetSize.width / 232, targetSize.height / 208)
switch template.previewStyle {
case .travelMemoir:
UIColor.black.withAlphaComponent(0.24).setFill()
context.cgContext.fill(bounds)
UIColor.white.setStroke()
let oval = UIBezierPath(ovalIn: CGRect(
x: bounds.midX - 50 * unit,
y: 28 * unit,
width: 100 * unit,
height: 100 * unit
))
oval.lineWidth = 9 * unit
oval.stroke()
drawCaption(
"TRAVEL\nMEMOIR",
in: CGRect(x: 30 * unit, y: bounds.height - 62 * unit, width: bounds.width - 60 * unit, height: 46 * unit),
alignment: .center,
fontSize: 17 * unit
)
case .minimal:
UIColor.white.withAlphaComponent(0.9).setFill()
context.cgContext.fill(CGRect(x: bounds.width * 0.55, y: 0, width: bounds.width * 0.45, height: bounds.height))
drawCaption(
"LESS\nIS MORE",
in: CGRect(x: bounds.width * 0.62, y: bounds.midY - 27 * unit, width: bounds.width * 0.3, height: 54 * unit),
alignment: .left,
color: .black,
fontSize: 17 * unit
)
case .scenicStory:
UIColor.black.withAlphaComponent(0.2).setFill()
context.cgContext.fill(bounds)
UIColor.white.setStroke()
let inset = 20 * unit
let frame = UIBezierPath(rect: bounds.insetBy(dx: inset, dy: inset))
frame.lineWidth = 4 * unit
frame.stroke()
drawCaption(
"SCENIC STORY",
in: CGRect(x: 34 * unit, y: bounds.height - 58 * unit, width: bounds.width - 68 * unit, height: 24 * unit),
alignment: .center,
fontSize: 17 * unit
)
case .film:
let stripHeight = 24 * unit
UIColor.black.withAlphaComponent(0.72).setFill()
context.cgContext.fill(CGRect(x: 0, y: 0, width: bounds.width, height: stripHeight))
context.cgContext.fill(CGRect(x: 0, y: bounds.height - stripHeight, width: bounds.width, height: stripHeight))
UIColor.white.withAlphaComponent(0.9).setFill()
var x = 10 * unit
while x <= bounds.width - 16 * unit {
context.cgContext.fill(CGRect(x: x, y: 6 * unit, width: 14 * unit, height: 10 * unit))
context.cgContext.fill(CGRect(
x: x,
y: bounds.height - 16 * unit,
width: 14 * unit,
height: 10 * unit
))
x += 30 * unit
}
drawCaption(
"THE JOURNEY",
in: CGRect(x: 28 * unit, y: bounds.height - 60 * unit, width: bounds.width - 56 * unit, height: 24 * unit),
alignment: .center,
fontSize: 17 * unit
)
}
}
}
private static func aspectFillRect(imageSize: CGSize, bounds: CGRect) -> CGRect {
guard imageSize.width > 0, imageSize.height > 0 else { return bounds }
let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height)
let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
return CGRect(
x: bounds.midX - drawSize.width / 2,
y: bounds.midY - drawSize.height / 2,
width: drawSize.width,
height: drawSize.height
)
}
private static func drawCaption(
_ text: String,
in rect: CGRect,
alignment: NSTextAlignment,
color: UIColor = .white,
fontSize: CGFloat
) {
let paragraph = NSMutableParagraphStyle()
paragraph.alignment = alignment
text.draw(
in: rect,
withAttributes: [
.font: UIFont.systemFont(ofSize: fontSize, weight: .bold),
.foregroundColor: color,
.paragraphStyle: paragraph,
]
)
}
}
@@ -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 "修图结果无法读取"
}
}
}
@@ -36,6 +36,7 @@ final class TravelAlbumDetailViewModel {
private(set) var sortOption: SortOption = .createdDesc private(set) var sortOption: SortOption = .createdDesc
private(set) var isLoading = true private(set) var isLoading = true
private(set) var isRefreshing = false private(set) var isRefreshing = false
private(set) var isUsingPurchasedDemoData = false
private(set) var isSelectionMode = false private(set) var isSelectionMode = false
private(set) var selectedMaterialIds: Set<Int> = [] private(set) var selectedMaterialIds: Set<Int> = []
@@ -126,6 +127,7 @@ final class TravelAlbumDetailViewModel {
if reset { if reset {
currentPage = 1 currentPage = 1
canLoadMore = false canLoadMore = false
isUsingPurchasedDemoData = false
} else { } else {
guard canLoadMore, !isLoadingMore else { return } guard canLoadMore, !isLoadingMore else { return }
isLoadingMore = true isLoadingMore = true
@@ -140,8 +142,15 @@ final class TravelAlbumDetailViewModel {
orderBy: sortOption.rawValue, orderBy: sortOption.rawValue,
isPurchased: selectedTab == .purchased ? 1 : nil isPurchased: selectedTab == .purchased ? 1 : nil
) )
if reset,
selectedTab == .purchased,
response.total == 0,
response.list.isEmpty {
applyPurchasedDemoMaterials()
} else {
materials = reset ? response.list : materials + response.list materials = reset ? response.list : materials + response.list
canLoadMore = materials.count < response.total canLoadMore = materials.count < response.total
}
if selectedTab == .all { if selectedTab == .all {
allPhotoCount = response.total allPhotoCount = response.total
} }
@@ -154,6 +163,12 @@ final class TravelAlbumDetailViewModel {
currentPage -= 1 currentPage -= 1
} }
isLoadingMore = false isLoadingMore = false
if reset, selectedTab == .purchased {
// 已购接口暂不可用时继续提供稳定的本地演示数据,保证产品流程可演示。
applyPurchasedDemoMaterials()
notifyStateChange()
return
}
onShowMessage?(error.localizedDescription) onShowMessage?(error.localizedDescription)
} }
} }
@@ -225,6 +240,44 @@ final class TravelAlbumDetailViewModel {
} }
} }
/// 已购照片接口无数据时生成稳定的本地素材;真实接口一旦返回数据即不再使用。
private func applyPurchasedDemoMaterials() {
let demoItems: [(fileName: String, fileSize: Int, assetName: String, createdAt: String)] = [
("IMG_0724_0001.jpeg", 2_684_928, "purchased_alpine_lake", "2026-07-24 11:25:18"),
("IMG_0724_0002.jpeg", 3_215_360, "purchased_forest_waterfall", "2026-07-24 11:27:46"),
("IMG_0724_0003.jpeg", 2_936_832, "purchased_grassland_path", "2026-07-24 11:31:09"),
("IMG_0724_0004.jpeg", 3_481_600, "purchased_lakeside_flowers", "2026-07-24 11:34:22"),
]
var materials = demoItems.enumerated().map { index, item in
TravelAlbumMaterial(
id: -((albumId * 10) + index + 1),
userEquityTravelId: albumId,
status: 2,
fileName: item.fileName,
fileType: 1,
fileUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
fileSize: item.fileSize,
coverUrl: TravelAlbumMaterial.bundledImageURL(assetName: item.assetName),
isPurchased: true,
createdAt: item.createdAt,
updatedAt: item.createdAt
)
}
switch sortOption {
case .createdAsc:
materials.sort { $0.createdAt < $1.createdAt }
case .createdDesc:
materials.sort { $0.createdAt > $1.createdAt }
case .fileNameAsc:
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedAscending }
case .fileNameDesc:
materials.sort { $0.fileName.localizedStandardCompare($1.fileName) == .orderedDescending }
}
self.materials = materials
canLoadMore = false
isUsingPurchasedDemoData = true
}
private func notifyStateChange() { private func notifyStateChange() {
onStateChange?() onStateChange?()
} }
@@ -42,13 +42,16 @@ final class TravelAlbumEntryViewModel {
private let currentScenicIdProvider: () -> Int private let currentScenicIdProvider: () -> Int
private let dateProvider: () -> Date private let dateProvider: () -> Date
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
init( init(
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, 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.currentScenicIdProvider = currentScenicIdProvider
self.dateProvider = dateProvider self.dateProvider = dateProvider
self.autoRetouchConfigurationStore = autoRetouchConfigurationStore
} }
/// 重新拉取相册列表。 /// 重新拉取相册列表。
@@ -110,6 +113,7 @@ final class TravelAlbumEntryViewModel {
freeCount: String, freeCount: String,
singlePrice: String, singlePrice: String,
packagePrice: String, packagePrice: String,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
order: TravelAlbumAvailableOrder?, order: TravelAlbumAvailableOrder?,
api: any TravelAlbumServing api: any TravelAlbumServing
) async { ) async {
@@ -132,6 +136,10 @@ final class TravelAlbumEntryViewModel {
onShowMessage?("请输入有效的单张照片价格") onShowMessage?("请输入有效的单张照片价格")
return return
} }
if !autoRetouchConfiguration.isValid {
onShowMessage?("请选择修图模板")
return
}
let albumName: String let albumName: String
switch mode { switch mode {
@@ -174,6 +182,7 @@ final class TravelAlbumEntryViewModel {
do { do {
let response = try await api.create(request) let response = try await api.create(request)
autoRetouchConfigurationStore.save(autoRetouchConfiguration, albumID: response.id)
isCreateSheetVisible = false isCreateSheetVisible = false
onShowMessage?("任务创建成功") onShowMessage?("任务创建成功")
onCreatedAlbum?(response) onCreatedAlbum?(response)
@@ -63,7 +63,7 @@ final class WiredCameraTransferViewModel {
private(set) var sonyMTPHint: String? private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false private(set) var isContentCatalogReady = false
let retouchOption = "不修图" private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload { private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() } didSet { notifyStateChanged() }
@@ -80,6 +80,9 @@ final class WiredCameraTransferViewModel {
private let api: any TravelAlbumServing private let api: any TravelAlbumServing
private let appStore: AppStore private let appStore: AppStore
private let userDefaults: UserDefaults private let userDefaults: UserDefaults
private let autoRetouchConfigurationStore: any TravelAlbumAutoRetouchConfigurationStoring
private let autoRetouchProcessor: any TravelAlbumAutoRetouchProcessing
private let aiEditResultStore: TravelAlbumAIEditResultStore
private var currentDriver: CameraDriver? private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:] private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
@@ -94,12 +97,16 @@ final class WiredCameraTransferViewModel {
albumTitle: String, albumTitle: String,
headerPhone: String, headerPhone: String,
scenicSpotLabel: String? = nil, scenicSpotLabel: String? = nil,
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
connectionManager: (any WiredCameraConnectionManaging)? = nil, connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil, uploader: (any TravelAlbumOTGUploading)? = nil,
api: (any TravelAlbumServing)? = nil, api: (any TravelAlbumServing)? = nil,
appStore: AppStore = .shared, 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.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
@@ -112,7 +119,15 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore self.appStore = appStore
self.userDefaults = userDefaults self.userDefaults = userDefaults
self.transferMode = Self.persistedTransferMode(in: 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 {
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
}
} }
/// 相机状态文案。 /// 相机状态文案。
@@ -213,6 +228,11 @@ final class WiredCameraTransferViewModel {
transferMode.title transferMode.title
} }
/// 自动 AI 修图设置项展示文案。
var retouchOption: String {
autoRetouchConfiguration.uploadOptionTitle
}
/// 指定上传弹窗选项。 /// 指定上传弹窗选项。
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] { var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases TravelAlbumOTGSpecifyUploadOption.allCases
@@ -301,6 +321,15 @@ final class WiredCameraTransferViewModel {
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey) 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) { func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return } guard photoFormatOption != option else { return }
@@ -462,8 +491,31 @@ final class WiredCameraTransferViewModel {
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传") 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) { func deletePhoto(photoId: String) {
if let materialId = persistedRecordsById[photoId]?.materialId {
aiEditResultStore.remove(materialID: materialId)
}
storage.remove(albumId: albumId, photoId: photoId) storage.remove(albumId: albumId, photoId: photoId)
persistedRecordsById.removeValue(forKey: photoId) persistedRecordsById.removeValue(forKey: photoId)
selectedPhotoIds.remove(photoId) selectedPhotoIds.remove(photoId)
@@ -473,6 +525,9 @@ final class WiredCameraTransferViewModel {
/// 清空当前相册本地 OTG 缓存。 /// 清空当前相册本地 OTG 缓存。
func clearLocalAlbumCache() { func clearLocalAlbumCache() {
persistedRecordsById.values.compactMap(\.materialId).forEach { materialId in
aiEditResultStore.remove(materialID: materialId)
}
storage.clearAlbum(albumId: albumId) storage.clearAlbum(albumId: albumId)
persistedRecordsById = [:] persistedRecordsById = [:]
photos = [] photos = []
@@ -487,9 +542,27 @@ final class WiredCameraTransferViewModel {
private func loadPersistedPhotos() { private func loadPersistedPhotos() {
let records = storage.load(albumId: albumId) let records = storage.load(albumId: albumId)
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
restorePersistedAutoRetouchResults(records)
applyMergedPhotos() 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() { private func syncServerUploadStatuses() {
serverStatusSyncTask?.cancel() serverStatusSyncTask?.cancel()
guard albumId > 0 else { return } guard albumId > 0 else { return }
@@ -588,6 +661,8 @@ final class WiredCameraTransferViewModel {
} }
private func uploadPhoto(id: String) async { private func uploadPhoto(id: String) async {
// 每张照片开始上传时固定一次配置,避免修图过程中切换模板导致结果不一致。
let retouchConfiguration = autoRetouchConfiguration
do { do {
let record = try await localRecordForUpload(id: id) let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
@@ -597,13 +672,89 @@ final class WiredCameraTransferViewModel {
) { [weak self] progress in ) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) 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 { } catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(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 { private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id], if let record = persistedRecordsById[id],
!record.localPath.isEmpty, !record.localPath.isEmpty,
@@ -618,7 +769,8 @@ final class WiredCameraTransferViewModel {
status: TravelAlbumOTGUploadStatus, status: TravelAlbumOTGUploadStatus,
progress: Int, progress: Int,
error: String?, error: String?,
remoteUrl: String? = nil remoteUrl: String? = nil,
materialId: Int? = nil
) { ) {
var record = persistedRecordsById[id] var record = persistedRecordsById[id]
if record == nil, let item = photos.first(where: { $0.id == id }) { if record == nil, let item = photos.first(where: { $0.id == id }) {
@@ -637,7 +789,10 @@ final class WiredCameraTransferViewModel {
errorMessage: error, errorMessage: error,
albumId: albumId, albumId: albumId,
userId: appStore.session.userId, 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 } guard var record else { return }
@@ -647,6 +802,35 @@ final class WiredCameraTransferViewModel {
if let remoteUrl { if let remoteUrl {
record.remoteUrl = 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) record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId) storage.upsert(record, albumId: albumId)
+66 -4
View File
@@ -8,6 +8,7 @@ import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate { class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow? var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController?
func scene( func scene(
_ scene: UIScene, _ scene: UIScene,
@@ -18,8 +19,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
AppNavigationBarAppearance.applyGlobalAppearance() AppNavigationBarAppearance.applyGlobalAppearance()
let debugPreviewRoot = makeAccountDeletionDebugPreviewRootIfNeeded()
if debugPreviewRoot == nil {
enforceAccountDeletionStateBeforeLaunch()
}
let window = UIWindow(windowScene: windowScene) let window = UIWindow(windowScene: windowScene)
window.rootViewController = AppRouter.makeRootViewController() window.rootViewController = debugPreviewRoot ?? AppRouter.makeRootViewController()
window.makeKeyAndVisible() window.makeKeyAndVisible()
self.window = window self.window = window
(UIApplication.shared.delegate as? AppDelegate)?.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() { @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() PushNotificationManager.shared.handleLogout()
AppStore.shared.logout() AppStore.shared.logout()
AppRouter.setRoot(.login, on: window) AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogout() { @objc private func handleUserDidLogout() {
PushNotificationManager.shared.handleLogout() transitionToLogin()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
} }
@objc private func handleUserDidLogin() { @objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window) AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async { DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted() PushNotificationManager.shared.handleLoginCompleted()
@@ -108,4 +156,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func handleAccountDidSwitch() { @objc private func handleAccountDidSwitch() {
PushNotificationManager.shared.handleAccountSwitched() 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() { override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF6F7FA)
tableView.backgroundColor = .clear tableView.backgroundColor = .clear
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 142 tableView.estimatedRowHeight = 224
tableView.contentInset.top = AppSpacing.sm tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
tableView.verticalScrollIndicatorInsets.top = AppSpacing.sm tableView.verticalScrollIndicatorInsets = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.md, right: 0)
tableView.dataSource = self tableView.dataSource = self
tableView.delegate = self tableView.delegate = self
tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier) tableView.register(CooperationAcquirerCell.self, forCellReuseIdentifier: CooperationAcquirerCell.reuseIdentifier)
@@ -245,6 +246,17 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
cell.onEditCommission = { [weak self] in cell.onEditCommission = { [weak self] in
self?.presentCommissionDialog(for: acquirer) 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 = { cell.onCall = {
let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines) let phone = acquirer.displayPhone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return } guard !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { return }
@@ -445,22 +445,25 @@ final class CooperationAcquirerCell: UITableViewCell {
var onEditRemark: (() -> Void)? var onEditRemark: (() -> Void)?
var onEditCommission: (() -> Void)? var onEditCommission: (() -> Void)?
var onViewCommissionLogs: (() -> Void)?
var onCall: (() -> Void)? var onCall: (() -> Void)?
private let cardView = UIView() private let cardView = UIView()
private let avatarContainer = UIView() private let avatarContainer = UIView()
private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill")) private let avatarIconView = UIImageView(image: UIImage(systemName: "person.fill"))
private let nameTitleLabel = UILabel()
private let nameValueLabel = UILabel() private let nameValueLabel = UILabel()
private let editRemarkButton = UIButton(type: .system)
private let phoneTitleLabel = UILabel()
private let phoneValueLabel = UILabel() private let phoneValueLabel = UILabel()
private let callButton = UIButton(type: .system) private let callButton = UIButton(type: .system)
private let bindTimeTitleLabel = UILabel() private let commissionContainer = UIView()
private let bindTimeValueLabel = UILabel()
private let commissionTitleLabel = UILabel() private let commissionTitleLabel = UILabel()
private let commissionValueLabel = 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 editCommissionButton = UIButton(type: .system)
private let commissionLogButton = UIButton(type: .system)
private let actionStack = UIStackView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier) super.init(style: style, reuseIdentifier: reuseIdentifier)
@@ -469,135 +472,180 @@ final class CooperationAcquirerCell: UITableViewCell {
contentView.backgroundColor = .clear contentView.backgroundColor = .clear
cardView.backgroundColor = .white 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.shadowColor = UIColor.black.cgColor
cardView.layer.shadowOpacity = 0.04 cardView.layer.shadowOpacity = 0.04
cardView.layer.shadowOffset = CGSize(width: 0, height: 1) cardView.layer.shadowOffset = CGSize(width: 0, height: 3)
cardView.layer.shadowRadius = 2 cardView.layer.shadowRadius = 8
cardView.accessibilityIdentifier = "cooperation_acquirer_card"
avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08) avatarContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
avatarContainer.layer.cornerRadius = AppRadius.sm avatarContainer.layer.cornerRadius = 24
avatarContainer.clipsToBounds = true avatarContainer.clipsToBounds = true
avatarIconView.tintColor = AppColor.primary avatarIconView.tintColor = AppColor.primary
avatarIconView.contentMode = .scaleAspectFit avatarIconView.contentMode = .scaleAspectFit
configureTitleLabel(nameTitleLabel, text: "获客员名称") nameValueLabel.font = .systemFont(ofSize: 17, weight: .semibold)
configureTitleLabel(phoneTitleLabel, text: "手机号")
configureTitleLabel(bindTimeTitleLabel, text: "绑定时间")
configureTitleLabel(commissionTitleLabel, text: "分成比例")
nameValueLabel.font = .systemFont(ofSize: 15, weight: .bold)
nameValueLabel.textColor = AppColor.textPrimary nameValueLabel.textColor = AppColor.textPrimary
nameValueLabel.numberOfLines = 1 nameValueLabel.numberOfLines = 1
nameValueLabel.lineBreakMode = .byTruncatingTail nameValueLabel.lineBreakMode = .byTruncatingTail
nameValueLabel.accessibilityIdentifier = "cooperation_acquirer_name"
[phoneValueLabel, bindTimeValueLabel, commissionValueLabel].forEach { label in phoneValueLabel.font = .systemFont(ofSize: 14)
label.font = .systemFont(ofSize: 13) phoneValueLabel.textColor = AppColor.textTertiary
label.textColor = AppColor.textSecondary phoneValueLabel.numberOfLines = 1
label.numberOfLines = 1 phoneValueLabel.lineBreakMode = .byTruncatingTail
label.lineBreakMode = .byTruncatingTail phoneValueLabel.accessibilityIdentifier = "cooperation_acquirer_phone"
}
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)
callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
callButton.tintColor = AppColor.primary callButton.tintColor = AppColor.primary
callButton.backgroundColor = AppColor.primaryLight 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.imageView?.contentMode = .scaleAspectFit
callButton.accessibilityLabel = "拨打获客员电话"
callButton.accessibilityIdentifier = "cooperation_acquirer_call"
callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside)
editCommissionButton.setTitle("修改比例", for: .normal) commissionContainer.backgroundColor = AppColor.primary.withAlphaComponent(0.06)
editCommissionButton.titleLabel?.font = .systemFont(ofSize: 13) commissionContainer.layer.cornerRadius = AppRadius.lg
editCommissionButton.setTitleColor(AppColor.primary, for: .normal)
editCommissionButton.setConfigurationContentInsets( commissionTitleLabel.text = "分成比例"
NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) 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) 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) contentView.addSubview(cardView)
cardView.addSubview(avatarContainer) cardView.addSubview(avatarContainer)
avatarContainer.addSubview(avatarIconView) avatarContainer.addSubview(avatarIconView)
cardView.addSubview(nameTitleLabel)
cardView.addSubview(nameValueLabel) cardView.addSubview(nameValueLabel)
cardView.addSubview(editRemarkButton)
cardView.addSubview(phoneTitleLabel)
cardView.addSubview(phoneValueLabel) cardView.addSubview(phoneValueLabel)
cardView.addSubview(callButton) cardView.addSubview(callButton)
cardView.addSubview(bindTimeTitleLabel) cardView.addSubview(commissionContainer)
commissionContainer.addSubview(commissionTitleLabel)
commissionContainer.addSubview(commissionValueLabel)
cardView.addSubview(bindTimeIconView)
cardView.addSubview(bindTimeValueLabel) cardView.addSubview(bindTimeValueLabel)
cardView.addSubview(commissionTitleLabel) cardView.addSubview(dividerView)
cardView.addSubview(commissionValueLabel) cardView.addSubview(actionStack)
cardView.addSubview(editCommissionButton)
cardView.snp.makeConstraints { make in 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 avatarContainer.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(14) make.top.leading.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalToSuperview() make.size.equalTo(48)
make.size.equalTo(44)
} }
avatarIconView.snp.makeConstraints { make in avatarIconView.snp.makeConstraints { make in
make.center.equalToSuperview() make.center.equalToSuperview()
make.size.equalTo(22) make.size.equalTo(24)
}
nameTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(AppSpacing.md)
make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
} }
nameValueLabel.snp.makeConstraints { make in nameValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(nameTitleLabel) make.top.equalTo(avatarContainer).offset(2)
make.leading.equalTo(nameTitleLabel.snp.trailing).offset(AppSpacing.sm) make.leading.equalTo(avatarContainer.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualTo(editRemarkButton.snp.leading).offset(-AppSpacing.xs) make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
}
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)
} }
phoneValueLabel.snp.makeConstraints { make in phoneValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(phoneTitleLabel) make.top.equalTo(nameValueLabel.snp.bottom).offset(AppSpacing.xxs)
make.leading.equalTo(phoneTitleLabel.snp.trailing).offset(AppSpacing.sm) make.leading.equalTo(nameValueLabel)
make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.xs) make.trailing.lessThanOrEqualTo(callButton.snp.leading).offset(-AppSpacing.sm)
} }
callButton.snp.makeConstraints { make in callButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(14) make.top.trailing.equalToSuperview().inset(AppSpacing.md)
make.centerY.equalTo(phoneTitleLabel) make.size.equalTo(AppSpacing.minTouchTarget)
make.size.equalTo(30)
} }
bindTimeTitleLabel.snp.makeConstraints { make in commissionContainer.snp.makeConstraints { make in
make.top.equalTo(phoneTitleLabel.snp.bottom).offset(AppSpacing.xs) make.top.equalTo(avatarContainer.snp.bottom).offset(14)
make.leading.equalTo(nameTitleLabel) make.leading.equalToSuperview().inset(AppSpacing.md)
} make.width.equalTo(120)
bindTimeValueLabel.snp.makeConstraints { make in make.height.equalTo(64)
make.centerY.equalTo(bindTimeTitleLabel)
make.leading.equalTo(bindTimeTitleLabel.snp.trailing).offset(AppSpacing.sm)
make.trailing.lessThanOrEqualToSuperview().inset(14)
} }
commissionTitleLabel.snp.makeConstraints { make in commissionTitleLabel.snp.makeConstraints { make in
make.top.equalTo(bindTimeTitleLabel.snp.bottom).offset(AppSpacing.xs) make.top.equalToSuperview().offset(AppSpacing.xs)
make.leading.equalTo(nameTitleLabel) make.centerX.equalToSuperview()
make.bottom.equalToSuperview().inset(AppSpacing.md)
} }
commissionValueLabel.snp.makeConstraints { make in commissionValueLabel.snp.makeConstraints { make in
make.centerY.equalTo(commissionTitleLabel) make.top.equalTo(commissionTitleLabel.snp.bottom).offset(2)
make.leading.equalTo(commissionTitleLabel.snp.trailing).offset(AppSpacing.sm) make.centerX.equalToSuperview()
make.trailing.lessThanOrEqualTo(editCommissionButton.snp.leading).offset(-AppSpacing.xs) make.leading.greaterThanOrEqualToSuperview().offset(AppSpacing.xs)
make.trailing.lessThanOrEqualToSuperview().inset(AppSpacing.xs)
} }
editCommissionButton.snp.makeConstraints { make in bindTimeIconView.snp.makeConstraints { make in
make.centerY.equalTo(commissionTitleLabel) make.leading.equalTo(commissionContainer.snp.trailing).offset(AppSpacing.md)
make.trailing.equalToSuperview().inset(AppSpacing.xs) 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() super.prepareForReuse()
onEditRemark = nil onEditRemark = nil
onEditCommission = nil onEditCommission = nil
onViewCommissionLogs = nil
onCall = nil onCall = nil
} }
func configure(with acquirer: CooperativeSalerEntity) { func configure(with acquirer: CooperativeSalerEntity) {
nameValueLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName nameValueLabel.text = acquirer.displayName.isEmpty ? "—" : acquirer.displayName
phoneValueLabel.text = acquirer.displayPhone.isEmpty ? "—" : CooperationOrderPhoneMask.mask(acquirer.displayPhone) 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 commissionValueLabel.text = acquirer.displayCommissionRate.isEmpty ? "—" : acquirer.displayCommissionRate
} }
private func configureTitleLabel(_ label: UILabel, text: String) { private func configureActionButton(
label.text = text _ button: UIButton,
label.font = .systemFont(ofSize: 13) title: String,
label.textColor = AppColor.textTertiary isPrimary: Bool,
label.setContentHuggingPriority(.required, for: .horizontal) accessibilityIdentifier: String
label.setContentCompressionResistancePriority(.required, for: .horizontal) ) {
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 editRemarkTapped() { onEditRemark?() }
@objc private func editCommissionTapped() { onEditCommission?() } @objc private func editCommissionTapped() { onEditCommission?() }
@objc private func commissionLogTapped() { onViewCommissionLogs?() }
@objc private func callTapped() { onCall?() } @objc private func callTapped() { onCall?() }
} }
@@ -7,6 +7,10 @@ import CoreLocation
import SnapKit import SnapKit
import UIKit import UIKit
#if targetEnvironment(simulator)
import MapKit
#endif
/// 地图缩放/定位控件。 /// 地图缩放/定位控件。
final class LocationReportMapControlStack: UIView { 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 { final class LocationReportMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)? var onMapTap: ((CLLocationCoordinate2D) -> Void)?
@@ -381,3 +490,4 @@ final class LocationReportMapView: UIView, MAMapViewDelegate {
shouldCenterOnNextUserLocation = false shouldCenterOnNextUserLocation = false
} }
} }
#endif
@@ -273,12 +273,53 @@ final class LoginViewController: BaseViewController {
do { do {
let resolution = try await viewModel.login(authAPI: authAPI) 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 { switch resolution {
case let .completed(response, account): case let .completed(response, account):
completeLogin(with: response, account: account) completeLogin(with: response, account: account)
case .needsAccountSelection: case .needsAccountSelection:
break 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 { } catch is CancellationError {
return return
} catch { } catch {
+25
View File
@@ -8,6 +8,8 @@ import Foundation
/// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。 /// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。
final class LoginViewModel { final class LoginViewModel {
private let accountDeletionService: any AccountDeletionServing
private(set) var account = "" private(set) var account = ""
private(set) var password = "" private(set) var password = ""
private(set) var isPrivacyChecked = false private(set) var isPrivacyChecked = false
@@ -16,6 +18,11 @@ final class LoginViewModel {
private(set) var isSelectingAccount = false private(set) var isSelectingAccount = false
private(set) var pendingAccountSelection: AccountSelectionPayload? private(set) var pendingAccountSelection: AccountSelectionPayload?
/// 创建登录 ViewModel,并注入注销状态服务以支持7天内恢复账号。
init(accountDeletionService: any AccountDeletionServing = AccountDeletionMockService.shared) {
self.accountDeletionService = accountDeletionService
}
var isLoginEnabled: Bool { var isLoginEnabled: Bool {
isValidPhone && !trimmedPassword.isEmpty isValidPhone && !trimmedPassword.isEmpty
} }
@@ -122,9 +129,27 @@ final class LoginViewModel {
username: normalizedUsername, username: normalizedUsername,
password: trimmedPassword 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) 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。 /// 选择一个账号并调用 set-user 换取正式 token。
func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse { func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse {
guard let payload = pendingAccountSelection, payload.hasTempToken else { guard let payload = pendingAccountSelection, payload.hasTempToken else {

Some files were not shown because too many files have changed in this diff Show More