Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1175066b9 | ||
|
|
5cf4409bad | ||
|
|
6fe9928b49 | ||
|
|
6129a1632f | ||
|
|
1cdce616e4 | ||
|
|
212607fc82 | ||
|
|
0cf0a41a60 | ||
|
|
f863004c8d | ||
|
|
61c56c7adb | ||
|
|
413a9603a4 | ||
|
|
f87b13cc05 | ||
|
|
0e0415eec4 | ||
|
|
7ccbc5568d | ||
|
|
787a166263 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 |
@@ -0,0 +1,281 @@
|
|||||||
|
# 相册管理与 AI 修图功能说明
|
||||||
|
|
||||||
|
> 文档日期:2026-08-20
|
||||||
|
> 适用范围:新增相册 → 相册任务 → 相册管理 → 照片上传与传输 → 照片预览与 AI 修图
|
||||||
|
> 数据说明:相册和照片列表、有线相机连接及原图上传继续使用项目已有真实数据能力;传输模式与相册自动修图模板保存在本地。自动修图目前使用本地图像效果演示,修图结果图和任务状态可随 OTG 相册恢复;手动原图精修、氛围感、封面模板和剩余 9 张额度仍为本地演示数据。
|
||||||
|
|
||||||
|
## 1. 照片预览、左右滑动与底部操作
|
||||||
|
|
||||||
|
### 功能说明
|
||||||
|
|
||||||
|
用户在“相册管理”照片网格中点击任意缩略图,进入单张照片预览页。页面只保留一个返回按钮,导航区域展示当前照片文件名和文件大小;照片区域支持左右滑动查看同一相册内的上一张或下一张照片。
|
||||||
|
|
||||||
|
预览页统一使用黑色沉浸式自定义头部,不再混用系统导航栏:左侧为圆形返回按钮,中间展示文件名和文件大小,右侧展示“当前张数/总张数”。切换照片时三处信息同步更新。照片预览与原图精修、氛围感、封面模板预览都直接使用同一个全屏模态容器,不进入项目导航栈,因此不会叠加系统白色导航栏或出现两个返回按钮;关闭后回到进入前的相册管理页或修图弹窗。
|
||||||
|
|
||||||
|
底部固定展示以下三个操作,不展示打印按钮:
|
||||||
|
|
||||||
|
| 操作 | 点击结果 | 操作期间或异常处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AI修图 | 打开当前照片的 AI 修图弹窗 | 当前照片处于“修图中”时按钮置灰,避免重复提交 |
|
||||||
|
| 删除 | 二次确认后删除当前照片 | 删除成功后自动定位到相邻照片;删除最后一张后返回相册管理 |
|
||||||
|
| 刷新 | 重新加载当前照片及最新修图结果 | 刷新失败时不清空当前已显示内容,可再次点击 |
|
||||||
|
|
||||||
|
左右滑动时同步刷新文件名、文件大小、原图/修图后状态和底部按钮状态。第一张继续向右滑、最后一张继续向左滑时保持当前照片,不循环跳转。
|
||||||
|
|
||||||
|
页面底部图标使用项目内 `TravelAlbumPreviewIcons` SVG 资源,确保 AI 修图、删除、刷新三个入口视觉一致。
|
||||||
|
|
||||||
|
### 状态流转图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 本功能验收
|
||||||
|
|
||||||
|
- [ ] 从任意缩略图进入时只显示一个返回入口。
|
||||||
|
- [ ] 文件名和文件大小与当前照片一致。
|
||||||
|
- [ ] 导航头部保持黑色沉浸样式,右侧张数随照片切换同步变化。
|
||||||
|
- [ ] 左右滑动后照片和文件信息同步变化。
|
||||||
|
- [ ] 首尾照片不会越界或循环。
|
||||||
|
- [ ] 底部仅显示 AI修图、删除、刷新,不显示打印。
|
||||||
|
- [ ] 删除当前照片后能正确定位或退出预览。
|
||||||
|
|
||||||
|
## 2. 上传照片与传输模式选择
|
||||||
|
|
||||||
|
### 功能说明
|
||||||
|
|
||||||
|
相册管理处于普通状态时,用户点击底部“上传照片”,先从底部弹出“选择传输模式”,展示“边拍边传”和“拍后传输”两个选项。点击任一选项后弹窗关闭,并进入当前相册的有线传输页面;点击“取消”或向下关闭弹窗时停留在相册管理,不改变现有照片和选择状态。
|
||||||
|
|
||||||
|
| 传输模式 | 弹窗说明 | 下一页初始状态 | 后续行为 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 边拍边传 | 相机拍摄后,照片自动传输并上传到当前相册 | 传输模式显示“边拍边传” | 新传入 App 的照片自动上传 |
|
||||||
|
| 拍后传输 | 拍摄完成后,再选择相机照片批量传输 | 传输模式显示“拍后传输” | 新照片先保留,用户后续选择并上传 |
|
||||||
|
|
||||||
|
本次弹窗选择优先于本机上一次保存的传输模式,并同步成为新的最近使用模式,确保下一页展示和实际传输行为一致。相册 ID、相册名称、联系电话及照片数据继续沿用相册管理页面的真实数据,不创建新的相册或假照片。
|
||||||
|
|
||||||
|
### 自动 AI 修图
|
||||||
|
|
||||||
|
新建相册时在价格字段下方固定展示“不修图”和“AI 修图”两个选项,默认选中“不修图”。点击“AI 修图”后再从底部弹出模板选择;每个模板同时展示本地演示照片套用该效果后的缩略图和效果说明。未选模板时不会开启 AI 修图。相册创建成功后,配置按真实相册 ID 保存,上传页仅回显当前一级方式“不修图 / AI 修图”。
|
||||||
|
|
||||||
|
有线传输页顶部的设置项也仅回显“不修图 / AI 修图”。点击后底部弹窗的第一层始终只有这两个选项;选择“AI 修图”后进入带效果缩略图的模板列表,可返回第一层重新选择。新配置只影响尚未开始的上传任务。每张照片仍先按原流程上传原图,得到服务端素材 ID 后才执行自动修图:
|
||||||
|
|
||||||
|
| 阶段 | 列表展示 | 统计归类 | 失败处理 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 原图上传 | 蓝色“上传中”及进度 | 全部 | 进入上传失败,可重传 |
|
||||||
|
| 自动修图 | 蓝色“修图中” | 全部 | 不回退原图上传结果 |
|
||||||
|
| 修图成功 | 绿色“已上传”,缩略图右上角显示紫色“修” | 已上传 | 使用修图后结果预览 |
|
||||||
|
| 修图失败 | 红色“修图失败” | 已上传 | 更多菜单选择“重新修图”,不重复上传原图 |
|
||||||
|
|
||||||
|
修图中退出 App 时,再次进入会恢复为“修图失败”并允许重试,避免任务长期停留在处理中。
|
||||||
|
|
||||||
|
进入有线传输页面后,非批量选择状态下点击任意照片缩略图,预览页会使用当前 Tab 筛选后的照片顺序,并从被点击照片开始展示。用户可左右滑动查看上一张或下一张,顶部页码随照片同步变化;没有可用预览地址的照片不进入预览序列,也不占用页码。第一张继续向右滑、最后一张继续向左滑时均停留在边界,不循环跳转。处于批量上传选择状态时,点击照片仍执行选择或取消选择,不打开预览。
|
||||||
|
|
||||||
|
### 状态流转图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 本功能验收
|
||||||
|
|
||||||
|
- [ ] 点击“上传照片”后先显示底部传输模式弹窗,不直接进入下一页。
|
||||||
|
- [ ] 弹窗同时显示“边拍边传”和“拍后传输”及对应说明。
|
||||||
|
- [ ] 选择“边拍边传”后,下一页模式显示为“边拍边传”。
|
||||||
|
- [ ] 选择“拍后传输”后,下一页模式显示为“拍后传输”。
|
||||||
|
- [ ] 新建相册固定展示“不修图”和“AI 修图”两个选项。
|
||||||
|
- [ ] 选择“AI 修图”后才弹出模板列表,每个模板都能看到差异化效果图。
|
||||||
|
- [ ] 上传页一级弹窗只有两种修图方式,新配置仅影响后续上传。
|
||||||
|
- [ ] 修图中、修图成功“修”角标和修图失败三种状态展示正确。
|
||||||
|
- [ ] 修图失败仍计入“已上传”,点击“重新修图”不重复上传原图。
|
||||||
|
- [ ] 本次选择不会被上一次保存的模式覆盖。
|
||||||
|
- [ ] 取消或关闭弹窗后仍停留在相册管理。
|
||||||
|
- [ ] 非批量选择状态点击缩略图后,从被点击照片进入全屏预览。
|
||||||
|
- [ ] 预览可左右滑动查看当前 Tab 中的上一张和下一张,页码同步变化且首尾不循环。
|
||||||
|
- [ ] 批量选择状态点击照片只改变选择状态,不打开预览。
|
||||||
|
|
||||||
|
## 3. 相册管理多选与底部操作栏切换
|
||||||
|
|
||||||
|
### 功能说明
|
||||||
|
|
||||||
|
相册管理页普通状态下,底部显示“上传照片”。用户点击右上角选择按钮后进入多选状态,每张缩略图右上角显示选择圆圈,底部“上传照片”隐藏,替换为“AI修图”和“删除”。
|
||||||
|
|
||||||
|
| 多选状态 | 页面表现 | 可用操作 | 下一状态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 未进入多选 | 不显示选择圆圈,底部显示上传照片 | 点击照片进入预览;点击右上角选择 | 进入多选 |
|
||||||
|
| 已进入但未选照片 | 显示未选圆圈,AI修图和删除置灰 | 选择照片;再次点击右上角选择退出 | 已选择或退出 |
|
||||||
|
| 已选择照片 | 选中照片显示勾选,AI修图和删除可用 | 发起批量修图或批量删除 | 弹窗、处理中或删除结果 |
|
||||||
|
| 选中项含修图中的照片 | 保留当前选择,AI修图置灰 | 等待任务完成;删除仍按现有删除规则处理 | 任务结束后恢复 |
|
||||||
|
| 退出多选 | 清空本次选择,恢复上传照片 | 上传或进入照片预览 | 普通状态 |
|
||||||
|
|
||||||
|
批量删除必须二次确认;用户取消时不删除任何照片并保留当前选择。批量 AI 修图复用与单张预览相同的预设、任务状态和结果覆盖逻辑。
|
||||||
|
|
||||||
|
### 状态流转图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 本功能验收
|
||||||
|
|
||||||
|
- [ ] 进入多选后上传按钮消失,AI修图和删除出现。
|
||||||
|
- [ ] 未选择照片时两个批量按钮不可点击。
|
||||||
|
- [ ] 选择至少一张后两个批量按钮恢复可用。
|
||||||
|
- [ ] 包含修图中照片时不能再次发起 AI 修图。
|
||||||
|
- [ ] 退出多选后选择状态清空并恢复上传按钮。
|
||||||
|
|
||||||
|
## 4. AI 修图弹窗、原图精修、氛围感与按张收费模式
|
||||||
|
|
||||||
|
### 功能说明
|
||||||
|
|
||||||
|
用户从单张预览或相册多选操作栏点击“AI修图”后,以同一个底部弹窗展示 AI 修图配置。标题与弹窗顶部保留足够间距,标题下方展示“已选择 N 张照片”。弹窗内容可纵向滚动,取消和确定按钮固定在底部,照片较多并出现封面模块时也不会遮挡选择项。
|
||||||
|
|
||||||
|
“原图精修”是基础修图模块,使用横向滚动单选列表,每个预设同时展示名称和对应效果图:
|
||||||
|
|
||||||
|
| 预设 | 当前效果用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| 还原为原图 | 清除该照片已有修图结果和封面类型 |
|
||||||
|
| 写真-简约肖像 | 低饱和、提亮的人像效果 |
|
||||||
|
| 写真-清冷古风 | 清冷复古效果 |
|
||||||
|
| 旅拍-锦绣 | 提升饱和度和对比度 |
|
||||||
|
| 旅拍-远山 | 低饱和、柔和远景效果 |
|
||||||
|
| 旅拍-薄雾 | 提亮并降低对比度 |
|
||||||
|
| 油画-夏日 | 偏暖夏日效果 |
|
||||||
|
| 油画-浓郁 | 高饱和、高对比效果 |
|
||||||
|
|
||||||
|
原图精修默认选中“还原为原图”,用户点击其他预设后仅保留一个选中项。没有可用原图时,效果图位置显示稳定占位图,不影响弹窗选择。每张样式效果图右下角都有“预览”按钮,点击后全屏查看当前照片应用该样式的效果,不改变当前选中项。
|
||||||
|
|
||||||
|
“氛围感修图”位于原图精修下方,并明确标记为“选填”。该模块与原图精修保持一致,使用横向滚动效果图单选,当前本地演示包含日落暖阳、复古胶片、清透蓝调、森系薄雾、浓郁故事五种样式。默认不选中;点击一种样式后仅保留该项,再次点击当前样式即可取消。每个样式同样提供“预览”按钮。选中后不是把氛围效果叠加并合并到精修图中,而是为每张照片额外生成一张可独立查看的“氛围感”结果。
|
||||||
|
|
||||||
|
原图精修、氛围感、封面模板和“查看高清图”统一进入同一个沉浸式大图预览页。大图页不再套用系统导航栏,只显示一个返回按钮、当前样式名称和“双指缩放查看细节”提示;关闭后回到原弹窗或照片页,并保留关闭前的选择状态。
|
||||||
|
|
||||||
|
弹窗内的 Tips 会随当前组合实时变化:
|
||||||
|
|
||||||
|
| 当前选择 | 确认后的结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 仅原图精修 | 每张照片生成 1 个“精修后”结果 |
|
||||||
|
| 原图精修 + 氛围感修图 | 每张照片生成“精修后 + 氛围感”2 个独立结果 |
|
||||||
|
| 有封面、未选氛围感 | 每张照片生成精修结果,第一张选中照片额外生成封面 |
|
||||||
|
| 有封面、已选氛围感 | 每张照片生成精修和氛围感 2 个结果,第一张选中照片再额外生成封面 |
|
||||||
|
|
||||||
|
“修图模式”区域只读展示“AI精修 · 按张收费”和“剩余 9 张”,不再显示勾选圆圈,也不能点击选择。当前剩余数量为本地演示数据,不会真实扣减额度;未来接入服务端时由接口返回剩余额度,并以任务创建成功或服务端明确返回的扣减结果为准。
|
||||||
|
|
||||||
|
点击“确定”提交当前原图精修、氛围感和封面组合并关闭弹窗;点击“取消”或关闭弹窗时不创建任务、不改变旧修图结果,也不改变额度。
|
||||||
|
|
||||||
|
### 状态流转图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 本功能验收
|
||||||
|
|
||||||
|
- [ ] 标题与弹窗顶部间距自然,不贴近顶部。
|
||||||
|
- [ ] 已选照片数量准确。
|
||||||
|
- [ ] 原图精修预设同时展示文字和效果图,并可横向滚动、保持单选。
|
||||||
|
- [ ] 原图精修、氛围感和封面模板效果图均可通过小按钮全屏预览,预览不改变选择。
|
||||||
|
- [ ] 所有大图预览使用同一黑色头部、单一返回入口,并支持双指缩放。
|
||||||
|
- [ ] 氛围感修图横向展示多个效果图,默认不选中、保持单选,可再次点击当前样式取消。
|
||||||
|
- [ ] 同时选择原图精修和氛围感时,每张照片生成两个独立结果。
|
||||||
|
- [ ] Tips 能根据氛围感和封面选择动态说明实际输出结果。
|
||||||
|
- [ ] 修图模式只展示按张收费与剩余张数,不提供选择操作。
|
||||||
|
- [ ] 取消弹窗不会创建任务或覆盖旧结果。
|
||||||
|
|
||||||
|
## 5. AI 修图任务状态、失败重试与结果覆盖
|
||||||
|
|
||||||
|
### 功能说明
|
||||||
|
|
||||||
|
用户确认修图后,所选照片立即进入“修图中”。任务未结束前,相关 AI 修图入口置灰;即使用户离开照片预览页,本地演示任务仍会继续完成,并通过共享结果状态刷新相册缩略图。
|
||||||
|
|
||||||
|
| 任务状态 | 进入条件 | 页面表现 | 可用操作 | 后续状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 可发起 | 未修图、已有成功结果或上次任务已结束 | AI修图按钮可用 | 选择预设并确认 | 修图中 |
|
||||||
|
| 修图中 | 已提交预设,等待结果 | 按钮置灰;缩略图显示“修图中” | 查看其他照片、离开页面 | 成功或失败 |
|
||||||
|
| 成功 | 获取原图并完成本地效果处理 | 分别保存精修、氛围感和封面结果;默认展示“精修后”;按钮恢复 | 在已有结果间切换、再次修图、还原原图 | 新任务或已上传 |
|
||||||
|
| 失败 | 原图无法获取或处理失败 | 缩略图显示“修图失败”;按钮恢复 | 再次修图 | 修图中 |
|
||||||
|
|
||||||
|
成功后,照片预览页至少出现“原图 / 精修后”切换;本次选择了氛围感时增加“氛围感”,存在封面结果时增加“封面”。各结果互相独立,不把两个效果合成同一张图;有修图结果时还可以点击“查看高清图”。再次修图时,旧结果在新任务处理期间继续保留,新任务成功后整组替换为本次最新组合:本次未选择氛围感时会清除旧氛围感结果;新任务失败时保留旧成功图片,但状态优先显示“修图失败”,提醒用户本次任务未成功。
|
||||||
|
|
||||||
|
选择“还原为原图”会同时清除修图结果、失败状态和封面类型,缩略图恢复为“已上传”。删除照片时同步清理该照片的本地修图记录,避免已删除照片的结果残留。
|
||||||
|
|
||||||
|
### 状态流转图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 本功能验收
|
||||||
|
|
||||||
|
- [ ] 修图中不能重复点击 AI修图。
|
||||||
|
- [ ] 离开预览页后任务仍能结束并刷新缩略图。
|
||||||
|
- [ ] 成功后可切换查看原图、精修后以及本次实际生成的氛围感或封面。
|
||||||
|
- [ ] 同选原图精修和氛围感时两个结果均可独立查看。
|
||||||
|
- [ ] 再次修图成功后以本次结果组合覆盖旧结果。
|
||||||
|
- [ ] 失败后按钮恢复并允许重试。
|
||||||
|
- [ ] 还原原图后所有本地修图类型和结果被清除。
|
||||||
|
|
||||||
|
## 6. 选中 4 张及以上时的封面模板选择
|
||||||
|
|
||||||
|
### 功能说明
|
||||||
|
|
||||||
|
从相册管理多选照片并点击“AI修图”时,系统根据选中照片数量决定是否展示“封面风格模板”:
|
||||||
|
|
||||||
|
| 选中数量 | 弹窗表现 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1~3 张 | 展示“原图精修 + 氛围感修图(选填)”两个模块,不展示封面模板 |
|
||||||
|
| ≥ 4 张 | 展示“原图精修 + 氛围感修图(选填)+ 封面模板”三个模块,并提高弹窗高度 |
|
||||||
|
|
||||||
|
封面模板为横向滚动单选,默认选中“旅行纪念册”。每个模板效果图右下角提供“预览”按钮,点击后全屏查看完整排版,不改变当前模板选择。当前提供:
|
||||||
|
|
||||||
|
- 旅行纪念册
|
||||||
|
- 简约留白
|
||||||
|
- 景区故事
|
||||||
|
- 电影胶片
|
||||||
|
|
||||||
|
每个模板展示名称和以第一张选中照片生成的排版效果图。用户确认后,所有选中照片先生成原图精修结果;选择了氛围感时,所有照片再各自生成一个氛围感结果;第一张选中照片另外生成独立封面并作为封面承载图。封面不会替代精修或氛围感结果,用户进入预览后可分别切换查看。
|
||||||
|
|
||||||
|
当前所有封面模板 `quotaCost = 0`,弹窗显示“赠送 · 不占精修张数”,不会减少剩余修图额度。后续收费时保留同一选择流程,由服务端为模板返回大于 0 的 `quotaCost`,客户端在确认前显示实际消耗,并以服务端成功结果为准扣减;收费能力目前尚未实现。
|
||||||
|
|
||||||
|
再次对不少于 4 张照片选择新封面模板并修图时,新封面效果覆盖旧封面效果。封面承载图处理失败时不标记为封面成功,缩略图显示“修图失败”并允许重试。选择“还原为原图”时清除封面效果和封面类型。
|
||||||
|
|
||||||
|
### 状态流转图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 本功能验收
|
||||||
|
|
||||||
|
- [ ] 选中 3 张时不显示封面模板。
|
||||||
|
- [ ] 选中 4 张或更多时显示封面模板。
|
||||||
|
- [ ] 有封面模块时,弹窗同时完整显示原图精修、氛围感和封面三个风格模块。
|
||||||
|
- [ ] 封面模板可横向滚动且始终单选。
|
||||||
|
- [ ] 每个封面模板均可独立预览,关闭预览后保留原选择。
|
||||||
|
- [ ] 默认选中旅行纪念册。
|
||||||
|
- [ ] 当前显示赠送并且不消耗精修额度。
|
||||||
|
- [ ] 成功后第一张选中照片成为封面承载图。
|
||||||
|
- [ ] 再次生成封面时以新结果覆盖旧结果。
|
||||||
|
|
||||||
|
## 7. 相册缩略图结果类型与显示优先级
|
||||||
|
|
||||||
|
### 功能说明
|
||||||
|
|
||||||
|
相册管理缩略图左上角复用同一状态位,根据照片当前结果显示不同类型,让用户无需进入预览即可区分未修、处理中、已修、封面和失败照片。
|
||||||
|
|
||||||
|
| 缩略图状态 | 角标文案与颜色 | 缩略图内容 | 进入条件 | 可用操作 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 普通原图 | 绿色“已上传” | 服务端原图或缩略图 | 没有本地修图结果 | 预览、选择、AI修图、删除 |
|
||||||
|
| 处理中 | 蓝色“修图中” | 有旧成功结果时继续显示旧结果,否则显示原图 | AI 任务未结束 | 预览、删除;不能再次修图 |
|
||||||
|
| 普通修图结果 | 紫色“AI已修” | 最新精修图片 | 原图精修成功,可同时存在氛围感结果 | 预览原图/精修后/氛围感、再次修图、还原 |
|
||||||
|
| 封面结果 | 橙色“AI封面” | 所选封面模板排版图 | 封面承载图生成成功 | 预览、再次修图、重新生成封面、还原 |
|
||||||
|
| 修图失败 | 红色“修图失败” | 有旧成功结果时保留旧图,否则显示原图 | 最近一次任务失败 | 再次修图、删除 |
|
||||||
|
|
||||||
|
状态显示优先级为:
|
||||||
|
|
||||||
|
`修图中 > 修图失败 > AI封面 > AI已修 > 已上传`
|
||||||
|
|
||||||
|
该优先级保证处理中或失败状态不会被历史成功结果遮盖。普通修图成功后显示“AI已修”;封面模板成功后,第一张选中照片显示封面排版图和“AI封面”,其他照片继续显示“AI已修”。再次修图成功后缩略图切换为最新结果;还原原图后回到“已上传”;删除照片后对应本地状态一起删除。
|
||||||
|
|
||||||
|
当前状态和结果由内存数据源在相册管理与照片预览之间共享,尚不支持 App 重启后的恢复。服务端接入时至少需要返回素材 ID、任务状态、结果类型、结果图地址、原图精修预设 ID、是否生成氛围感、封面模板 ID、失败原因和额度消耗,客户端才能恢复完全一致的缩略图状态。
|
||||||
|
|
||||||
|
### 状态流转图
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 本功能验收
|
||||||
|
|
||||||
|
- [ ] 未修图照片显示“已上传”。
|
||||||
|
- [ ] 修图中照片显示“修图中”且不能重复提交。
|
||||||
|
- [ ] 普通成功结果显示“AI已修”和最新效果图。
|
||||||
|
- [ ] 封面成功结果显示“AI封面”和封面排版图。
|
||||||
|
- [ ] 最近一次任务失败时优先显示“修图失败”。
|
||||||
|
- [ ] 还原、覆盖和删除后缩略图状态同步更新。
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,606 @@
|
|||||||
|
//
|
||||||
|
// OfflineCollectionDailyViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 日清列表的分区。
|
||||||
|
enum OfflineDailyListSection: Hashable {
|
||||||
|
case records
|
||||||
|
case empty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清列表的条目数据。
|
||||||
|
enum OfflineDailyListItem: Hashable {
|
||||||
|
case record(OfflineCollectionRecord)
|
||||||
|
case empty(isToday: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款日清页,展示单营业日汇总、收款明细与补缴操作。
|
||||||
|
final class OfflineCollectionDailyViewController: BaseViewController {
|
||||||
|
private let viewModel: OfflineCollectionDailyViewModel
|
||||||
|
|
||||||
|
private let headerStack = UIStackView()
|
||||||
|
private let dateControl = UIButton(type: .system)
|
||||||
|
private let summaryCard = UIView()
|
||||||
|
private let summaryStatusLabel = UILabel()
|
||||||
|
private let totalMetric = OfflineDailyMetricView(title: "线下收款总额")
|
||||||
|
private let countMetric = OfflineDailyMetricView(title: "登记笔数")
|
||||||
|
private let settledMetric = OfflineDailyMetricView(title: "已补缴")
|
||||||
|
private let pendingMetric = OfflineDailyMetricView(title: "待补缴")
|
||||||
|
private let overdueBanner = UILabel()
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<OfflineDailyListSection, OfflineDailyListItem>!
|
||||||
|
private var currentSections: [OfflineDailyListSection] = []
|
||||||
|
private let bottomContainer = UIView()
|
||||||
|
private let settlementButton = UIButton(type: .system)
|
||||||
|
private var isFeedbackPresented = false
|
||||||
|
|
||||||
|
/// 使用指定营业日、收款上下文与 Mock 服务创建日清页。
|
||||||
|
init(
|
||||||
|
businessDate: String,
|
||||||
|
context: OfflineCollectionContext = .current(),
|
||||||
|
service: OfflineCollectionMockService = .shared
|
||||||
|
) {
|
||||||
|
viewModel = OfflineCollectionDailyViewModel(
|
||||||
|
businessDate: businessDate,
|
||||||
|
context: context,
|
||||||
|
service: service
|
||||||
|
)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "线下收款日清"
|
||||||
|
#if DEBUG
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "模拟失败",
|
||||||
|
style: .plain,
|
||||||
|
target: self,
|
||||||
|
action: #selector(simulateFailureTapped)
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackground
|
||||||
|
headerStack.axis = .vertical
|
||||||
|
headerStack.spacing = AppSpacing.sm
|
||||||
|
|
||||||
|
dateControl.contentHorizontalAlignment = .leading
|
||||||
|
dateControl.showsMenuAsPrimaryAction = true
|
||||||
|
var dateConfiguration = UIButton.Configuration.plain()
|
||||||
|
dateConfiguration.image = UIImage(systemName: "calendar")
|
||||||
|
dateConfiguration.imagePadding = AppSpacing.sm
|
||||||
|
dateConfiguration.baseForegroundColor = AppColor.textPrimary
|
||||||
|
dateConfiguration.background.backgroundColor = .white
|
||||||
|
dateConfiguration.background.cornerRadius = AppRadius.lg
|
||||||
|
dateConfiguration.contentInsets = NSDirectionalEdgeInsets(
|
||||||
|
top: 12,
|
||||||
|
leading: AppSpacing.md,
|
||||||
|
bottom: 12,
|
||||||
|
trailing: AppSpacing.md
|
||||||
|
)
|
||||||
|
dateControl.configuration = dateConfiguration
|
||||||
|
dateControl.accessibilityIdentifier = "offlineCollection.businessDate"
|
||||||
|
|
||||||
|
summaryCard.backgroundColor = .white
|
||||||
|
summaryCard.layer.cornerRadius = AppRadius.lg
|
||||||
|
summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
summaryStatusLabel.textAlignment = .center
|
||||||
|
summaryStatusLabel.textColor = AppColor.success
|
||||||
|
summaryStatusLabel.backgroundColor = UIColor(hex: 0xECFDF3)
|
||||||
|
summaryStatusLabel.layer.cornerRadius = 12
|
||||||
|
summaryStatusLabel.clipsToBounds = true
|
||||||
|
|
||||||
|
let firstMetricRow = UIStackView(arrangedSubviews: [totalMetric, countMetric])
|
||||||
|
firstMetricRow.axis = .horizontal
|
||||||
|
firstMetricRow.distribution = .fillEqually
|
||||||
|
let secondMetricRow = UIStackView(arrangedSubviews: [settledMetric, pendingMetric])
|
||||||
|
secondMetricRow.axis = .horizontal
|
||||||
|
secondMetricRow.distribution = .fillEqually
|
||||||
|
let metricStack = UIStackView(arrangedSubviews: [firstMetricRow, secondMetricRow])
|
||||||
|
metricStack.axis = .vertical
|
||||||
|
metricStack.spacing = AppSpacing.md
|
||||||
|
summaryCard.addSubview(metricStack)
|
||||||
|
summaryCard.addSubview(summaryStatusLabel)
|
||||||
|
metricStack.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
summaryStatusLabel.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
overdueBanner.text = "该营业日存在逾期未补缴,请尽快完成补缴"
|
||||||
|
overdueBanner.font = .systemFont(ofSize: 13, weight: .medium)
|
||||||
|
overdueBanner.textColor = AppColor.danger
|
||||||
|
overdueBanner.backgroundColor = UIColor(hex: 0xFFF1F0)
|
||||||
|
overdueBanner.layer.cornerRadius = AppRadius.md
|
||||||
|
overdueBanner.clipsToBounds = true
|
||||||
|
overdueBanner.textAlignment = .center
|
||||||
|
overdueBanner.numberOfLines = 0
|
||||||
|
|
||||||
|
tableView.backgroundColor = .clear
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.estimatedRowHeight = 92
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.register(OfflineCollectionRecordCell.self, forCellReuseIdentifier: OfflineCollectionRecordCell.reuseIdentifier)
|
||||||
|
tableView.register(OfflineCollectionEmptyCell.self, forCellReuseIdentifier: OfflineCollectionEmptyCell.reuseIdentifier)
|
||||||
|
dataSource = makeDataSource()
|
||||||
|
|
||||||
|
bottomContainer.backgroundColor = .white
|
||||||
|
bottomContainer.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
bottomContainer.layer.shadowOpacity = 0.06
|
||||||
|
bottomContainer.layer.shadowRadius = 8
|
||||||
|
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2)
|
||||||
|
|
||||||
|
var settlementConfiguration = UIButton.Configuration.filled()
|
||||||
|
settlementConfiguration.title = "补缴今日全部"
|
||||||
|
settlementConfiguration.baseBackgroundColor = AppColor.primary
|
||||||
|
settlementConfiguration.baseForegroundColor = .white
|
||||||
|
settlementConfiguration.cornerStyle = .medium
|
||||||
|
settlementConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||||
|
var outgoing = incoming
|
||||||
|
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
settlementButton.configuration = settlementConfiguration
|
||||||
|
settlementButton.accessibilityIdentifier = "offlineCollection.settle"
|
||||||
|
|
||||||
|
view.addSubview(headerStack)
|
||||||
|
[dateControl, summaryCard, overdueBanner].forEach(headerStack.addArrangedSubview)
|
||||||
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(bottomContainer)
|
||||||
|
bottomContainer.addSubview(settlementButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
headerStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
}
|
||||||
|
dateControl.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(50)
|
||||||
|
}
|
||||||
|
summaryCard.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(172)
|
||||||
|
}
|
||||||
|
overdueBanner.snp.makeConstraints { make in
|
||||||
|
make.height.greaterThanOrEqualTo(48)
|
||||||
|
}
|
||||||
|
bottomContainer.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
settlementButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
make.height.equalTo(50)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(headerStack.snp.bottom).offset(AppSpacing.sm)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(bottomContainer.snp.top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
settlementButton.addTarget(self, action: #selector(settlementTapped), for: .touchUpInside)
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
applyViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
viewModel.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
let datePrefix = viewModel.isToday ? "今日 · " : "营业日 · "
|
||||||
|
dateControl.configuration?.title = datePrefix + viewModel.businessDate
|
||||||
|
dateControl.menu = makeDateMenu()
|
||||||
|
|
||||||
|
totalMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.totalAmountFen))
|
||||||
|
countMetric.setValue("\(viewModel.summary.totalCount) 笔")
|
||||||
|
settledMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.settledAmountFen))
|
||||||
|
pendingMetric.setValue(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen), highlighted: viewModel.summary.pendingAmountFen > 0)
|
||||||
|
|
||||||
|
let isSettled = viewModel.summary.totalCount > 0 && viewModel.summary.pendingAmountFen == 0
|
||||||
|
summaryStatusLabel.text = " 本日已结清 "
|
||||||
|
summaryStatusLabel.isHidden = !isSettled
|
||||||
|
overdueBanner.isHidden = !viewModel.summary.hasOverdue
|
||||||
|
|
||||||
|
let isProcessing = viewModel.settlementState == .processing
|
||||||
|
settlementButton.isEnabled = viewModel.canSettle
|
||||||
|
var configuration = settlementButton.configuration
|
||||||
|
configuration?.showsActivityIndicator = isProcessing
|
||||||
|
if isProcessing {
|
||||||
|
configuration?.title = "补缴中"
|
||||||
|
} else if isSettled {
|
||||||
|
configuration?.title = "本日已全部补缴"
|
||||||
|
} else if viewModel.summary.pendingAmountFen == 0 {
|
||||||
|
configuration?.title = "当日暂无待补缴"
|
||||||
|
} else {
|
||||||
|
let prefix = viewModel.isToday ? "补缴今日全部" : "补缴本日全部"
|
||||||
|
configuration?.title = "\(prefix) \(OfflineCollectionMoney.display(viewModel.summary.pendingAmountFen))"
|
||||||
|
}
|
||||||
|
settlementButton.configuration = configuration
|
||||||
|
settlementButton.alpha = settlementButton.isEnabled || isProcessing ? 1 : 0.5
|
||||||
|
|
||||||
|
applySnapshot()
|
||||||
|
presentSettlementFeedbackIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applySnapshot() {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<OfflineDailyListSection, OfflineDailyListItem>()
|
||||||
|
currentSections = []
|
||||||
|
if viewModel.records.isEmpty {
|
||||||
|
currentSections.append(.empty)
|
||||||
|
snapshot.appendSections([.empty])
|
||||||
|
snapshot.appendItems([.empty(isToday: viewModel.isToday)], toSection: .empty)
|
||||||
|
} else {
|
||||||
|
currentSections.append(.records)
|
||||||
|
snapshot.appendSections([.records])
|
||||||
|
snapshot.appendItems(viewModel.records.map(OfflineDailyListItem.record), toSection: .records)
|
||||||
|
}
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: view.window != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDataSource() -> UITableViewDiffableDataSource<OfflineDailyListSection, OfflineDailyListItem> {
|
||||||
|
UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item in
|
||||||
|
switch item {
|
||||||
|
case let .record(record):
|
||||||
|
guard let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: OfflineCollectionRecordCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as? OfflineCollectionRecordCell else { return UITableViewCell() }
|
||||||
|
cell.apply(record: record)
|
||||||
|
return cell
|
||||||
|
case let .empty(isToday):
|
||||||
|
guard let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: OfflineCollectionEmptyCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as? OfflineCollectionEmptyCell else { return UITableViewCell() }
|
||||||
|
cell.apply(isToday: isToday)
|
||||||
|
cell.onRegister = { [weak self] in self?.openRegistration() }
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDateMenu() -> UIMenu {
|
||||||
|
let actions = viewModel.availableBusinessDates.map { [weak self] date in
|
||||||
|
let isCurrent = date == self?.viewModel.businessDate
|
||||||
|
let title = date == self?.viewModel.service.todayBusinessDate ? "今日 \(date)" : date
|
||||||
|
return UIAction(title: title, state: isCurrent ? .on : .off) { [weak self] _ in
|
||||||
|
self?.viewModel.selectBusinessDate(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UIMenu(title: "选择营业日", children: actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func presentSettlementFeedbackIfNeeded() {
|
||||||
|
guard !isFeedbackPresented else { return }
|
||||||
|
switch viewModel.settlementState {
|
||||||
|
case .idle, .processing:
|
||||||
|
return
|
||||||
|
case let .success(batch):
|
||||||
|
isFeedbackPresented = true
|
||||||
|
let message = """
|
||||||
|
已补缴 \(OfflineCollectionMoney.display(batch.amountFen))
|
||||||
|
共结清 \(batch.recordCount) 笔线下收款
|
||||||
|
补缴流水号:\(batch.id)
|
||||||
|
"""
|
||||||
|
let alert = UIAlertController(title: "补缴成功", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "完成", style: .default) { [weak self] _ in
|
||||||
|
self?.isFeedbackPresented = false
|
||||||
|
self?.viewModel.clearSettlementFeedback()
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
case let .failed(message):
|
||||||
|
isFeedbackPresented = true
|
||||||
|
let alert = UIAlertController(title: "补缴失败", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "暂不处理", style: .cancel) { [weak self] _ in
|
||||||
|
self?.isFeedbackPresented = false
|
||||||
|
self?.viewModel.cancelPreparedSettlement()
|
||||||
|
self?.viewModel.clearSettlementFeedback()
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "重新补缴", style: .default) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.isFeedbackPresented = false
|
||||||
|
self.viewModel.clearSettlementFeedback()
|
||||||
|
Task { await self.viewModel.confirmSettlement() }
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openRegistration() {
|
||||||
|
guard viewModel.isToday else { return }
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
OfflineCollectionRegistrationViewController(
|
||||||
|
context: viewModel.context,
|
||||||
|
service: viewModel.service
|
||||||
|
),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func settlementTapped() {
|
||||||
|
do {
|
||||||
|
let request = try viewModel.prepareSettlement()
|
||||||
|
let message = """
|
||||||
|
营业日:\(request.businessDate)
|
||||||
|
待补缴记录:\(request.recordIds.count) 笔
|
||||||
|
本次补缴金额:\(OfflineCollectionMoney.display(request.amountFen))
|
||||||
|
|
||||||
|
补缴成功后,本批次记录将统一变为“已补缴”。
|
||||||
|
"""
|
||||||
|
let alert = UIAlertController(title: "确认补缴", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||||
|
self?.viewModel.cancelPreparedSettlement()
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(
|
||||||
|
title: "确认补缴 \(OfflineCollectionMoney.display(request.amountFen))",
|
||||||
|
style: .default
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.viewModel.confirmSettlement() }
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
} catch {
|
||||||
|
showToast(error.localizedDescription)
|
||||||
|
viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func simulateFailureTapped() {
|
||||||
|
viewModel.simulateNextSettlementFailure()
|
||||||
|
showToast("下一次补缴将模拟失败,可验收失败后重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OfflineCollectionDailyViewController: UITableViewDelegate {
|
||||||
|
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||||
|
currentSections[safe: section] == .empty ? 0 : 40
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||||
|
guard let listSection = currentSections[safe: section], listSection != .empty else { return nil }
|
||||||
|
let container = UIView()
|
||||||
|
container.backgroundColor = AppColor.pageBackground
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = "线下收款明细"
|
||||||
|
label.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
label.textColor = AppColor.textPrimary
|
||||||
|
container.addSubview(label)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
make.bottom.equalToSuperview().inset(AppSpacing.xs)
|
||||||
|
}
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清汇总卡片中的单项指标视图。
|
||||||
|
final class OfflineDailyMetricView: UIView {
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let valueLabel = UILabel()
|
||||||
|
|
||||||
|
/// 使用指标名称创建汇总视图。
|
||||||
|
init(title: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
titleLabel.text = title
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新指标值及是否使用警示色强调。
|
||||||
|
func setValue(_ value: String, highlighted: Bool = false) {
|
||||||
|
valueLabel.text = value
|
||||||
|
valueLabel.textColor = highlighted ? UIColor(hex: 0xD97706) : AppColor.textPrimary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
titleLabel.font = .systemFont(ofSize: 12)
|
||||||
|
titleLabel.textColor = AppColor.textTertiary
|
||||||
|
valueLabel.font = .systemFont(ofSize: 19, weight: .bold)
|
||||||
|
valueLabel.textColor = AppColor.textPrimary
|
||||||
|
valueLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
valueLabel.minimumScaleFactor = 0.75
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, valueLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 4
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 2, left: 4, bottom: 2, right: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线下收款明细单元格。
|
||||||
|
final class OfflineCollectionRecordCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "OfflineCollectionRecordCell"
|
||||||
|
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let timeLabel = UILabel()
|
||||||
|
private let methodLabel = UILabel()
|
||||||
|
private let identifierLabel = UILabel()
|
||||||
|
private let amountLabel = UILabel()
|
||||||
|
private let statusLabel = UILabel()
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用线下收款记录刷新单元格。
|
||||||
|
func apply(record: OfflineCollectionRecord) {
|
||||||
|
timeLabel.text = OfflineCollectionDate.timeText(record.registeredAt)
|
||||||
|
methodLabel.text = record.paymentMethod.displayName
|
||||||
|
identifierLabel.text = record.id
|
||||||
|
amountLabel.text = OfflineCollectionMoney.display(record.amountFen)
|
||||||
|
statusLabel.text = " \(record.status.displayName) "
|
||||||
|
|
||||||
|
let color: UIColor
|
||||||
|
let background: UIColor
|
||||||
|
switch record.status {
|
||||||
|
case .pending:
|
||||||
|
color = UIColor(hex: 0xD97706)
|
||||||
|
background = UIColor(hex: 0xFFF7E6)
|
||||||
|
case .settled:
|
||||||
|
color = AppColor.success
|
||||||
|
background = UIColor(hex: 0xECFDF3)
|
||||||
|
case .overdue:
|
||||||
|
color = AppColor.danger
|
||||||
|
background = UIColor(hex: 0xFFF1F0)
|
||||||
|
}
|
||||||
|
statusLabel.textColor = color
|
||||||
|
statusLabel.backgroundColor = background
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = AppRadius.lg
|
||||||
|
|
||||||
|
timeLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
timeLabel.textColor = AppColor.textPrimary
|
||||||
|
methodLabel.font = .systemFont(ofSize: 14)
|
||||||
|
methodLabel.textColor = AppColor.textSecondary
|
||||||
|
identifierLabel.font = .systemFont(ofSize: 11)
|
||||||
|
identifierLabel.textColor = AppColor.textTertiary
|
||||||
|
amountLabel.font = .systemFont(ofSize: 19, weight: .bold)
|
||||||
|
amountLabel.textColor = AppColor.textPrimary
|
||||||
|
amountLabel.textAlignment = .right
|
||||||
|
statusLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||||||
|
statusLabel.textAlignment = .center
|
||||||
|
statusLabel.layer.cornerRadius = 11
|
||||||
|
statusLabel.clipsToBounds = true
|
||||||
|
|
||||||
|
let leftStack = UIStackView(arrangedSubviews: [timeLabel, methodLabel, identifierLabel])
|
||||||
|
leftStack.axis = .vertical
|
||||||
|
leftStack.spacing = 4
|
||||||
|
let rightStack = UIStackView(arrangedSubviews: [amountLabel, statusLabel])
|
||||||
|
rightStack.axis = .vertical
|
||||||
|
rightStack.alignment = .trailing
|
||||||
|
rightStack.spacing = 8
|
||||||
|
cardView.addSubview(leftStack)
|
||||||
|
cardView.addSubview(rightStack)
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.equalToSuperview().inset(5)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
}
|
||||||
|
leftStack.snp.makeConstraints { make in
|
||||||
|
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.trailing.lessThanOrEqualTo(rightStack.snp.leading).offset(-AppSpacing.sm)
|
||||||
|
}
|
||||||
|
rightStack.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
statusLabel.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(22)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 日清页在指定营业日无记录时展示的空状态单元格。
|
||||||
|
final class OfflineCollectionEmptyCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "OfflineCollectionEmptyCell"
|
||||||
|
var onRegister: (() -> Void)?
|
||||||
|
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let detailLabel = UILabel()
|
||||||
|
private let registerButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新空状态,仅今日允许前往登记。
|
||||||
|
func apply(isToday: Bool) {
|
||||||
|
registerButton.isHidden = !isToday
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
onRegister = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = .clear
|
||||||
|
contentView.backgroundColor = .clear
|
||||||
|
titleLabel.text = "当日暂无线下收款记录"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
detailLabel.text = "完成线下收款后,请及时登记"
|
||||||
|
detailLabel.font = .systemFont(ofSize: 13)
|
||||||
|
detailLabel.textColor = AppColor.textSecondary
|
||||||
|
detailLabel.textAlignment = .center
|
||||||
|
registerButton.setTitle("去登记", for: .normal)
|
||||||
|
registerButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||||
|
registerButton.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel, registerButton])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = AppSpacing.sm
|
||||||
|
stack.alignment = .center
|
||||||
|
contentView.addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().inset(48)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
make.bottom.equalToSuperview().inset(32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func registerTapped() {
|
||||||
|
onRegister?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Collection {
|
||||||
|
/// 安全按下标读取元素,超出范围时返回 nil。
|
||||||
|
subscript(safe index: Index) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//
|
||||||
|
// OfflineCollectionRegistrationViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 线下收款登记页,仅收集金额和线下收款方式。
|
||||||
|
final class OfflineCollectionRegistrationViewController: BaseViewController {
|
||||||
|
private let viewModel: OfflineCollectionRegistrationViewModel
|
||||||
|
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
private let amountCard = UIView()
|
||||||
|
private let amountField = UITextField()
|
||||||
|
private let methodCard = UIView()
|
||||||
|
private let methodStack = UIStackView()
|
||||||
|
private var methodButtons: [OfflineCollectionPaymentMethod: OfflinePaymentMethodButton] = [:]
|
||||||
|
private let explanationCard = UIView()
|
||||||
|
private let contextLabel = UILabel()
|
||||||
|
private let bottomContainer = UIView()
|
||||||
|
private let submitButton = UIButton(type: .system)
|
||||||
|
private var hasFocusedAmountField = false
|
||||||
|
|
||||||
|
/// 使用指定的收款上下文与 Mock 服务创建登记页。
|
||||||
|
init(
|
||||||
|
context: OfflineCollectionContext = .current(),
|
||||||
|
service: OfflineCollectionMockService = .shared
|
||||||
|
) {
|
||||||
|
viewModel = OfflineCollectionRegistrationViewModel(context: context, service: service)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "线下收款登记"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackground
|
||||||
|
scrollView.keyboardDismissMode = .interactive
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = AppSpacing.md
|
||||||
|
|
||||||
|
configureCard(amountCard)
|
||||||
|
let amountTitleLabel = makeTitleLabel("收款金额")
|
||||||
|
let currencyLabel = UILabel()
|
||||||
|
currencyLabel.text = "¥"
|
||||||
|
currencyLabel.font = .systemFont(ofSize: 34, weight: .semibold)
|
||||||
|
currencyLabel.textColor = AppColor.textPrimary
|
||||||
|
|
||||||
|
amountField.placeholder = "0.00"
|
||||||
|
amountField.font = .systemFont(ofSize: 38, weight: .bold)
|
||||||
|
amountField.textColor = AppColor.textPrimary
|
||||||
|
amountField.keyboardType = .decimalPad
|
||||||
|
amountField.clearButtonMode = .whileEditing
|
||||||
|
amountField.delegate = self
|
||||||
|
amountField.accessibilityLabel = "收款金额"
|
||||||
|
|
||||||
|
let amountHelperLabel = UILabel()
|
||||||
|
amountHelperLabel.text = "单笔金额 0.01~99,999.99 元,最多两位小数"
|
||||||
|
amountHelperLabel.font = .systemFont(ofSize: 12)
|
||||||
|
amountHelperLabel.textColor = AppColor.textTertiary
|
||||||
|
|
||||||
|
let amountRow = UIStackView(arrangedSubviews: [currencyLabel, amountField])
|
||||||
|
amountRow.axis = .horizontal
|
||||||
|
amountRow.alignment = .center
|
||||||
|
amountRow.spacing = AppSpacing.sm
|
||||||
|
let amountStack = UIStackView(arrangedSubviews: [amountTitleLabel, amountRow, amountHelperLabel])
|
||||||
|
amountStack.axis = .vertical
|
||||||
|
amountStack.spacing = AppSpacing.sm
|
||||||
|
amountCard.addSubview(amountStack)
|
||||||
|
amountStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(AppSpacing.lg)
|
||||||
|
}
|
||||||
|
amountField.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(58)
|
||||||
|
}
|
||||||
|
|
||||||
|
configureCard(methodCard)
|
||||||
|
let methodTitleLabel = makeTitleLabel("收款方式")
|
||||||
|
methodStack.axis = .horizontal
|
||||||
|
methodStack.distribution = .fillEqually
|
||||||
|
methodStack.spacing = AppSpacing.sm
|
||||||
|
for method in OfflineCollectionPaymentMethod.allCases {
|
||||||
|
let button = OfflinePaymentMethodButton(method: method)
|
||||||
|
button.addTarget(self, action: #selector(methodTapped(_:)), for: .touchUpInside)
|
||||||
|
methodButtons[method] = button
|
||||||
|
methodStack.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
let methodContentStack = UIStackView(arrangedSubviews: [methodTitleLabel, methodStack])
|
||||||
|
methodContentStack.axis = .vertical
|
||||||
|
methodContentStack.spacing = AppSpacing.md
|
||||||
|
methodCard.addSubview(methodContentStack)
|
||||||
|
methodContentStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(AppSpacing.lg)
|
||||||
|
}
|
||||||
|
methodStack.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(64)
|
||||||
|
}
|
||||||
|
|
||||||
|
configureCard(explanationCard)
|
||||||
|
let explanationIcon = UIImageView(image: UIImage(systemName: "info.circle.fill"))
|
||||||
|
explanationIcon.tintColor = AppColor.primary
|
||||||
|
explanationIcon.contentMode = .scaleAspectFit
|
||||||
|
let explanationLabel = UILabel()
|
||||||
|
explanationLabel.text = "登记后将计入今日待补缴,不生成订单。"
|
||||||
|
explanationLabel.font = .systemFont(ofSize: 14)
|
||||||
|
explanationLabel.textColor = AppColor.textSecondary
|
||||||
|
explanationLabel.numberOfLines = 0
|
||||||
|
explanationCard.addSubview(explanationIcon)
|
||||||
|
explanationCard.addSubview(explanationLabel)
|
||||||
|
explanationIcon.snp.makeConstraints { make in
|
||||||
|
make.leading.top.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.width.height.equalTo(20)
|
||||||
|
}
|
||||||
|
explanationLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(explanationIcon.snp.trailing).offset(AppSpacing.sm)
|
||||||
|
make.trailing.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.top.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
|
||||||
|
contextLabel.text = "当前:\(viewModel.context.collectorName) · \(viewModel.context.storeName) · \(viewModel.context.scenicName)"
|
||||||
|
contextLabel.font = .systemFont(ofSize: 12)
|
||||||
|
contextLabel.textColor = AppColor.textTertiary
|
||||||
|
contextLabel.numberOfLines = 0
|
||||||
|
contextLabel.textAlignment = .center
|
||||||
|
|
||||||
|
bottomContainer.backgroundColor = .white
|
||||||
|
bottomContainer.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
bottomContainer.layer.shadowOpacity = 0.06
|
||||||
|
bottomContainer.layer.shadowRadius = 8
|
||||||
|
bottomContainer.layer.shadowOffset = CGSize(width: 0, height: -2)
|
||||||
|
|
||||||
|
var submitConfiguration = UIButton.Configuration.filled()
|
||||||
|
submitConfiguration.title = "确认登记"
|
||||||
|
submitConfiguration.baseBackgroundColor = AppColor.primary
|
||||||
|
submitConfiguration.baseForegroundColor = .white
|
||||||
|
submitConfiguration.cornerStyle = .medium
|
||||||
|
submitConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||||
|
var outgoing = incoming
|
||||||
|
outgoing.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
submitButton.configuration = submitConfiguration
|
||||||
|
submitButton.accessibilityIdentifier = "offlineCollection.submit"
|
||||||
|
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(contentStack)
|
||||||
|
[amountCard, methodCard, explanationCard, contextLabel].forEach(contentStack.addArrangedSubview)
|
||||||
|
view.addSubview(bottomContainer)
|
||||||
|
bottomContainer.addSubview(submitButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
bottomContainer.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||||||
|
}
|
||||||
|
submitButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
make.height.equalTo(50)
|
||||||
|
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
}
|
||||||
|
scrollView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.bottom.equalTo(bottomContainer.snp.top)
|
||||||
|
}
|
||||||
|
contentStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
amountField.addTarget(self, action: #selector(amountChanged), for: .editingChanged)
|
||||||
|
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
viewModel.onRegistrationSuccess = { [weak self] receipt in
|
||||||
|
Task { @MainActor in self?.showRegistrationSuccess(receipt) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
applyViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
guard !hasFocusedAmountField else { return }
|
||||||
|
hasFocusedAmountField = true
|
||||||
|
amountField.becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
if amountField.text != viewModel.amountText {
|
||||||
|
amountField.text = viewModel.amountText
|
||||||
|
}
|
||||||
|
methodButtons.forEach { method, button in
|
||||||
|
button.setSelected(method == viewModel.paymentMethod)
|
||||||
|
}
|
||||||
|
submitButton.isEnabled = viewModel.canSubmit
|
||||||
|
submitButton.configuration?.showsActivityIndicator = viewModel.isSubmitting
|
||||||
|
submitButton.configuration?.title = viewModel.isSubmitting ? "登记中" : "确认登记"
|
||||||
|
submitButton.alpha = submitButton.isEnabled || viewModel.isSubmitting ? 1 : 0.45
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func showRegistrationSuccess(_ receipt: OfflineCollectionRegistrationReceipt) {
|
||||||
|
amountField.resignFirstResponder()
|
||||||
|
let message = """
|
||||||
|
本次登记 \(OfflineCollectionMoney.display(receipt.record.amountFen))
|
||||||
|
今日累计线下收款 \(OfflineCollectionMoney.display(receipt.summary.totalAmountFen))
|
||||||
|
今日待补缴 \(OfflineCollectionMoney.display(receipt.summary.pendingAmountFen))
|
||||||
|
"""
|
||||||
|
let alert = UIAlertController(title: "登记成功", message: message, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "继续登记", style: .default) { [weak self] _ in
|
||||||
|
self?.viewModel.startAnotherRegistration()
|
||||||
|
self?.amountField.becomeFirstResponder()
|
||||||
|
})
|
||||||
|
alert.addAction(UIAlertAction(title: "查看今日明细", style: .default) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
let controller = OfflineCollectionDailyViewController(
|
||||||
|
businessDate: receipt.summary.businessDate,
|
||||||
|
context: self.viewModel.context,
|
||||||
|
service: self.viewModel.service
|
||||||
|
)
|
||||||
|
self.navigationController?.pushViewController(controller, animated: true)
|
||||||
|
})
|
||||||
|
present(alert, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTitleLabel(_ text: String) -> UILabel {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = text
|
||||||
|
label.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
label.textColor = AppColor.textPrimary
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureCard(_ card: UIView) {
|
||||||
|
card.backgroundColor = .white
|
||||||
|
card.layer.cornerRadius = AppRadius.lg
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func amountChanged() {
|
||||||
|
viewModel.updateAmount(amountField.text ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func methodTapped(_ sender: OfflinePaymentMethodButton) {
|
||||||
|
viewModel.selectPaymentMethod(sender.method)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func submitTapped() {
|
||||||
|
Task { await viewModel.submit() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OfflineCollectionRegistrationViewController: UITextFieldDelegate {
|
||||||
|
func textField(
|
||||||
|
_ textField: UITextField,
|
||||||
|
shouldChangeCharactersIn range: NSRange,
|
||||||
|
replacementString string: String
|
||||||
|
) -> Bool {
|
||||||
|
guard let current = textField.text,
|
||||||
|
let textRange = Range(range, in: current) else { return false }
|
||||||
|
let next = current.replacingCharacters(in: textRange, with: string)
|
||||||
|
return OfflineCollectionMoney.acceptsEditingText(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登记页的收款方式单选按钮。
|
||||||
|
final class OfflinePaymentMethodButton: UIControl {
|
||||||
|
let method: OfflineCollectionPaymentMethod
|
||||||
|
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
|
||||||
|
/// 使用指定收款方式创建单选按钮。
|
||||||
|
init(method: OfflineCollectionPaymentMethod) {
|
||||||
|
self.method = method
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新选中态视觉。
|
||||||
|
func setSelected(_ selected: Bool) {
|
||||||
|
isSelected = selected
|
||||||
|
backgroundColor = selected ? UIColor(hex: 0xEEF5FF) : AppColor.inputBackground
|
||||||
|
layer.borderColor = (selected ? AppColor.primary : UIColor.clear).cgColor
|
||||||
|
layer.borderWidth = selected ? 1.5 : 0
|
||||||
|
titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
layer.cornerRadius = AppRadius.md
|
||||||
|
clipsToBounds = true
|
||||||
|
accessibilityLabel = method.displayName
|
||||||
|
|
||||||
|
iconView.image = UIImage(named: method.assetName)?.withRenderingMode(.alwaysOriginal)
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
titleLabel.text = method.displayName
|
||||||
|
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.spacing = 5
|
||||||
|
stack.isUserInteractionEnabled = false
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.width.height.equalTo(22)
|
||||||
|
}
|
||||||
|
setSelected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//
|
||||||
|
// OfflineCollectionHomeView.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 收款首页中的线下收款入口、今日汇总和历史逾期提醒区域。
|
||||||
|
final class OfflineCollectionHomeView: UIView {
|
||||||
|
var onRegister: (() -> Void)?
|
||||||
|
var onOpenToday: (() -> Void)?
|
||||||
|
var onOpenOverdue: (() -> Void)?
|
||||||
|
|
||||||
|
private let contentStack = UIStackView()
|
||||||
|
private let sectionTitleLabel = UILabel()
|
||||||
|
|
||||||
|
private let overdueControl = UIControl()
|
||||||
|
private let overdueTitleLabel = UILabel()
|
||||||
|
private let overdueDetailLabel = UILabel()
|
||||||
|
private let overdueActionLabel = UILabel()
|
||||||
|
|
||||||
|
private let registerControl = UIControl()
|
||||||
|
private let registerIconView = UIImageView()
|
||||||
|
private let registerTitleLabel = UILabel()
|
||||||
|
private let registerSubtitleLabel = UILabel()
|
||||||
|
private let registerActionLabel = UILabel()
|
||||||
|
|
||||||
|
private let summaryControl = UIControl()
|
||||||
|
private let summaryTitleLabel = UILabel()
|
||||||
|
private let summaryAmountLabel = UILabel()
|
||||||
|
private let summaryCountLabel = UILabel()
|
||||||
|
private let summaryRegisteredLabel = UILabel()
|
||||||
|
private let summaryStatusLabel = UILabel()
|
||||||
|
private let summaryActionLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
setupConstraints()
|
||||||
|
bindActions()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据今日与历史逾期汇总刷新页面内容。
|
||||||
|
func apply(
|
||||||
|
today: OfflineDailySummary,
|
||||||
|
overdueDayCount: Int,
|
||||||
|
overdueRecordCount: Int,
|
||||||
|
overdueAmountFen: Int
|
||||||
|
) {
|
||||||
|
let hasOverdue = overdueDayCount > 0 && overdueAmountFen > 0
|
||||||
|
overdueControl.isHidden = !hasOverdue
|
||||||
|
overdueDetailLabel.text = "\(overdueDayCount) 个营业日,共 \(overdueRecordCount) 笔,待补缴 \(OfflineCollectionMoney.display(overdueAmountFen))"
|
||||||
|
|
||||||
|
summaryAmountLabel.text = OfflineCollectionMoney.display(today.pendingAmountFen)
|
||||||
|
summaryCountLabel.text = "\(today.pendingCount) 笔线下收款"
|
||||||
|
summaryRegisteredLabel.text = "今日已登记 \(OfflineCollectionMoney.display(today.totalAmountFen))"
|
||||||
|
let isSettled = today.totalCount > 0 && today.pendingAmountFen == 0
|
||||||
|
summaryStatusLabel.text = isSettled ? "今日已结清" : ""
|
||||||
|
summaryStatusLabel.isHidden = !isSettled
|
||||||
|
summaryActionLabel.text = today.pendingAmountFen > 0 ? "去补缴" : "查看明细"
|
||||||
|
summaryActionLabel.backgroundColor = today.pendingAmountFen > 0
|
||||||
|
? AppColor.primary
|
||||||
|
: UIColor(hex: 0xEEF2F7)
|
||||||
|
summaryActionLabel.textColor = today.pendingAmountFen > 0
|
||||||
|
? .white
|
||||||
|
: AppColor.textSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
contentStack.axis = .vertical
|
||||||
|
contentStack.spacing = AppSpacing.md
|
||||||
|
|
||||||
|
sectionTitleLabel.text = "线下收款"
|
||||||
|
sectionTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
sectionTitleLabel.textColor = AppColor.textPrimary
|
||||||
|
|
||||||
|
configureCard(overdueControl, backgroundColor: UIColor(hex: 0xFFF1F0))
|
||||||
|
overdueControl.layer.borderColor = UIColor(hex: 0xFFCCC7).cgColor
|
||||||
|
overdueControl.layer.borderWidth = 1
|
||||||
|
overdueTitleLabel.text = "存在逾期未补缴"
|
||||||
|
overdueTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
overdueTitleLabel.textColor = AppColor.danger
|
||||||
|
overdueDetailLabel.font = .systemFont(ofSize: 13)
|
||||||
|
overdueDetailLabel.textColor = UIColor(hex: 0x8C2F27)
|
||||||
|
overdueDetailLabel.numberOfLines = 0
|
||||||
|
configureActionLabel(overdueActionLabel, title: "立即处理", background: AppColor.danger, foreground: .white)
|
||||||
|
|
||||||
|
configureCard(registerControl, backgroundColor: .white)
|
||||||
|
registerIconView.image = UIImage(systemName: "plus.circle.fill")
|
||||||
|
registerIconView.tintColor = AppColor.primary
|
||||||
|
registerIconView.contentMode = .scaleAspectFit
|
||||||
|
registerTitleLabel.text = "线下收款登记"
|
||||||
|
registerTitleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||||
|
registerTitleLabel.textColor = AppColor.textPrimary
|
||||||
|
registerSubtitleLabel.text = "线下收款后,请及时登记并在当日完成补缴"
|
||||||
|
registerSubtitleLabel.font = .systemFont(ofSize: 13)
|
||||||
|
registerSubtitleLabel.textColor = AppColor.textSecondary
|
||||||
|
registerSubtitleLabel.numberOfLines = 0
|
||||||
|
configureActionLabel(registerActionLabel, title: "去登记", background: UIColor(hex: 0xEEF5FF), foreground: AppColor.primary)
|
||||||
|
|
||||||
|
configureCard(summaryControl, backgroundColor: .white)
|
||||||
|
summaryTitleLabel.text = "今日待补缴"
|
||||||
|
summaryTitleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||||
|
summaryTitleLabel.textColor = AppColor.textPrimary
|
||||||
|
summaryAmountLabel.font = .systemFont(ofSize: 30, weight: .bold)
|
||||||
|
summaryAmountLabel.textColor = AppColor.textPrimary
|
||||||
|
summaryCountLabel.font = .systemFont(ofSize: 13)
|
||||||
|
summaryCountLabel.textColor = UIColor(hex: 0xD97706)
|
||||||
|
summaryRegisteredLabel.font = .systemFont(ofSize: 13)
|
||||||
|
summaryRegisteredLabel.textColor = AppColor.textSecondary
|
||||||
|
summaryStatusLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
summaryStatusLabel.textColor = AppColor.success
|
||||||
|
configureActionLabel(summaryActionLabel, title: "查看明细", background: UIColor(hex: 0xEEF2F7), foreground: AppColor.textSecondary)
|
||||||
|
|
||||||
|
addSubview(contentStack)
|
||||||
|
[sectionTitleLabel, overdueControl, registerControl, summaryControl].forEach(contentStack.addArrangedSubview)
|
||||||
|
|
||||||
|
let overdueTextStack = UIStackView(arrangedSubviews: [overdueTitleLabel, overdueDetailLabel])
|
||||||
|
overdueTextStack.axis = .vertical
|
||||||
|
overdueTextStack.spacing = 5
|
||||||
|
overdueControl.addSubview(overdueTextStack)
|
||||||
|
overdueControl.addSubview(overdueActionLabel)
|
||||||
|
|
||||||
|
let registerTextStack = UIStackView(arrangedSubviews: [registerTitleLabel, registerSubtitleLabel])
|
||||||
|
registerTextStack.axis = .vertical
|
||||||
|
registerTextStack.spacing = 5
|
||||||
|
registerControl.addSubview(registerIconView)
|
||||||
|
registerControl.addSubview(registerTextStack)
|
||||||
|
registerControl.addSubview(registerActionLabel)
|
||||||
|
|
||||||
|
let summaryTextStack = UIStackView(arrangedSubviews: [
|
||||||
|
summaryTitleLabel,
|
||||||
|
summaryAmountLabel,
|
||||||
|
summaryCountLabel,
|
||||||
|
summaryRegisteredLabel,
|
||||||
|
summaryStatusLabel,
|
||||||
|
])
|
||||||
|
summaryTextStack.axis = .vertical
|
||||||
|
summaryTextStack.spacing = 6
|
||||||
|
summaryControl.addSubview(summaryTextStack)
|
||||||
|
summaryControl.addSubview(summaryActionLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
contentStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
sectionTitleLabel.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
overdueControl.snp.makeConstraints { make in
|
||||||
|
make.height.greaterThanOrEqualTo(84)
|
||||||
|
}
|
||||||
|
overdueControl.subviews.first?.snp.makeConstraints { make in
|
||||||
|
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.trailing.lessThanOrEqualTo(overdueActionLabel.snp.leading).offset(-AppSpacing.sm)
|
||||||
|
}
|
||||||
|
overdueActionLabel.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.height.equalTo(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerControl.snp.makeConstraints { make in
|
||||||
|
make.height.greaterThanOrEqualTo(96)
|
||||||
|
}
|
||||||
|
registerIconView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.width.height.equalTo(34)
|
||||||
|
}
|
||||||
|
registerTitleLabel.superview?.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(registerIconView.snp.trailing).offset(AppSpacing.sm)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.trailing.lessThanOrEqualTo(registerActionLabel.snp.leading).offset(-AppSpacing.sm)
|
||||||
|
}
|
||||||
|
registerActionLabel.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.height.equalTo(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryControl.snp.makeConstraints { make in
|
||||||
|
make.height.greaterThanOrEqualTo(180)
|
||||||
|
}
|
||||||
|
summaryTitleLabel.superview?.snp.makeConstraints { make in
|
||||||
|
make.leading.top.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.trailing.lessThanOrEqualTo(summaryActionLabel.snp.leading).offset(-AppSpacing.sm)
|
||||||
|
}
|
||||||
|
summaryActionLabel.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.bottom.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.height.equalTo(34)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindActions() {
|
||||||
|
overdueControl.addTarget(self, action: #selector(overdueTapped), for: .touchUpInside)
|
||||||
|
registerControl.addTarget(self, action: #selector(registerTapped), for: .touchUpInside)
|
||||||
|
summaryControl.addTarget(self, action: #selector(summaryTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureCard(_ control: UIControl, backgroundColor: UIColor) {
|
||||||
|
control.backgroundColor = backgroundColor
|
||||||
|
control.layer.cornerRadius = AppRadius.lg
|
||||||
|
control.clipsToBounds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureActionLabel(
|
||||||
|
_ label: UILabel,
|
||||||
|
title: String,
|
||||||
|
background: UIColor,
|
||||||
|
foreground: UIColor
|
||||||
|
) {
|
||||||
|
label.text = " \(title) "
|
||||||
|
label.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
label.textAlignment = .center
|
||||||
|
label.textColor = foreground
|
||||||
|
label.backgroundColor = background
|
||||||
|
label.layer.cornerRadius = 16
|
||||||
|
label.clipsToBounds = true
|
||||||
|
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func overdueTapped() {
|
||||||
|
onOpenOverdue?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func registerTapped() {
|
||||||
|
onRegister?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func summaryTapped() {
|
||||||
|
onOpenToday?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -11,14 +12,24 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
private let viewModel = PaymentCollectionDetailsViewModel()
|
private let viewModel = PaymentCollectionDetailsViewModel()
|
||||||
private let paymentAPI = NetworkServices.shared.paymentAPI
|
private let paymentAPI = NetworkServices.shared.paymentAPI
|
||||||
|
private let offlineCollectionViewModel = OfflineCollectionHomeViewModel()
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
|
private let contentContainerView = UIView()
|
||||||
private let contentStack = UIStackView()
|
private let contentStack = UIStackView()
|
||||||
|
|
||||||
|
private let backgroundImageView = UIImageView()
|
||||||
|
private let brandHeaderStack = UIStackView()
|
||||||
|
private let brandLogoImageView = UIImageView()
|
||||||
|
private let brandTitleLabel = UILabel()
|
||||||
|
private let brandSubtitleLabel = UILabel()
|
||||||
|
|
||||||
private let qrCardView = UIView()
|
private let qrCardView = UIView()
|
||||||
private let scenicNameLabel = UILabel()
|
private let scenicNameLabel = UILabel()
|
||||||
|
private let qrContainerView = UIView()
|
||||||
private let qrImageView = UIImageView()
|
private let qrImageView = UIImageView()
|
||||||
private let qrPlaceholderView = UIView()
|
private let qrPlaceholderView = UIView()
|
||||||
|
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
private let qrErrorLabel = UILabel()
|
private let qrErrorLabel = UILabel()
|
||||||
private let refreshButton = UIButton(type: .system)
|
private let refreshButton = UIButton(type: .system)
|
||||||
private let setAmountButton = UIButton(type: .system)
|
private let setAmountButton = UIButton(type: .system)
|
||||||
@@ -29,26 +40,76 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
private let scenicRow = PaymentInfoRowView(title: "景区名称")
|
private let scenicRow = PaymentInfoRowView(title: "景区名称")
|
||||||
private let merchantRow = PaymentInfoRowView(title: "收款方")
|
private let merchantRow = PaymentInfoRowView(title: "收款方")
|
||||||
private let staffRow = PaymentInfoRowView(title: "员工 ID")
|
private let staffRow = PaymentInfoRowView(title: "员工 ID")
|
||||||
|
private let photographerRow = PaymentInfoRowView(title: "摄影师名称")
|
||||||
|
private let storeRow = PaymentInfoRowView(title: "店铺名称")
|
||||||
|
|
||||||
private let recordRow = PaymentNavigationRowView(title: "收款记录")
|
private let recordRow = PaymentNavigationRowView(title: "收款记录")
|
||||||
|
|
||||||
|
private let settingsCardView = UIView()
|
||||||
private let voiceCardView = UIView()
|
private let voiceCardView = UIView()
|
||||||
private let voiceTitleLabel = UILabel()
|
private let voiceTitleLabel = UILabel()
|
||||||
private let voiceSwitch = UISwitch()
|
private let voiceSwitch = UISwitch()
|
||||||
|
private let offlineCollectionView = OfflineCollectionHomeView()
|
||||||
|
|
||||||
private var amountDialog: PaymentSetAmountDialogView?
|
private var amountDialog: PaymentSetAmountDialogView?
|
||||||
|
private var appliedBrandConfig: PayPageConfig?
|
||||||
|
private var appliedBrandingRefreshVersion = -1
|
||||||
|
|
||||||
|
private var previousStandardAppearance: UINavigationBarAppearance?
|
||||||
|
private var previousScrollEdgeAppearance: UINavigationBarAppearance?
|
||||||
|
private var previousCompactAppearance: UINavigationBarAppearance?
|
||||||
|
private var previousNavigationTintColor: UIColor?
|
||||||
|
private var previousNavigationTranslucency = false
|
||||||
|
|
||||||
|
private var brandScreenHeight: CGFloat {
|
||||||
|
UIScreen.main.bounds.height
|
||||||
|
}
|
||||||
|
|
||||||
|
private var brandLogoHeight: CGFloat {
|
||||||
|
if brandScreenHeight <= 700 { return 82 }
|
||||||
|
if brandScreenHeight <= 820 { return 96 }
|
||||||
|
return 112
|
||||||
|
}
|
||||||
|
|
||||||
|
private var brandQRContainerSize: CGFloat {
|
||||||
|
if brandScreenHeight <= 700 { return 170 }
|
||||||
|
if brandScreenHeight <= 820 { return 190 }
|
||||||
|
return 210
|
||||||
|
}
|
||||||
|
|
||||||
|
private var brandQRImageSize: CGFloat {
|
||||||
|
if brandScreenHeight <= 700 { return 146 }
|
||||||
|
if brandScreenHeight <= 820 { return 166 }
|
||||||
|
return 182
|
||||||
|
}
|
||||||
|
|
||||||
|
private var brandSectionSpacing: CGFloat {
|
||||||
|
if brandScreenHeight <= 700 { return 8 }
|
||||||
|
if brandScreenHeight <= 820 { return 10 }
|
||||||
|
return 12
|
||||||
|
}
|
||||||
|
|
||||||
|
private var brandHeaderToQRSpacing: CGFloat {
|
||||||
|
if brandScreenHeight <= 700 { return 16 }
|
||||||
|
if brandScreenHeight <= 820 { return 18 }
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "收款详情"
|
title = viewModel.usesNalatiBranding ? nil : "收款详情"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = AppColor.pageBackground
|
let usesBranding = viewModel.usesNalatiBranding
|
||||||
|
view.backgroundColor = usesBranding ? .clear : AppColor.pageBackground
|
||||||
|
scrollView.showsVerticalScrollIndicator = true
|
||||||
|
scrollView.isScrollEnabled = true
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
|
||||||
contentStack.axis = .vertical
|
contentStack.axis = .vertical
|
||||||
contentStack.spacing = AppSpacing.md
|
contentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.md
|
||||||
|
|
||||||
qrCardView.backgroundColor = .white
|
qrCardView.backgroundColor = usesBranding ? .clear : .white
|
||||||
qrCardView.layer.cornerRadius = AppRadius.lg
|
qrCardView.layer.cornerRadius = AppRadius.lg
|
||||||
|
|
||||||
scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
scenicNameLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
@@ -57,11 +118,22 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
qrImageView.contentMode = .scaleAspectFit
|
qrImageView.contentMode = .scaleAspectFit
|
||||||
qrImageView.isHidden = true
|
qrImageView.isHidden = true
|
||||||
|
if usesBranding {
|
||||||
|
qrContainerView.backgroundColor = .white
|
||||||
|
qrContainerView.layer.cornerRadius = 10
|
||||||
|
qrContainerView.clipsToBounds = true
|
||||||
|
qrContainerView.isHidden = true
|
||||||
|
qrContainerView.addSubview(qrImageView)
|
||||||
|
}
|
||||||
|
|
||||||
qrPlaceholderView.backgroundColor = AppColor.inputBackground
|
qrPlaceholderView.backgroundColor = AppColor.inputBackground
|
||||||
qrPlaceholderView.layer.cornerRadius = 24
|
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
|
||||||
qrPlaceholderView.isHidden = true
|
qrPlaceholderView.isHidden = true
|
||||||
|
|
||||||
|
qrLoadingIndicator.color = AppColor.primary
|
||||||
|
qrLoadingIndicator.hidesWhenStopped = true
|
||||||
|
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
|
||||||
|
|
||||||
qrErrorLabel.text = "未选景区或网络问题"
|
qrErrorLabel.text = "未选景区或网络问题"
|
||||||
qrErrorLabel.font = .systemFont(ofSize: 14)
|
qrErrorLabel.font = .systemFont(ofSize: 14)
|
||||||
qrErrorLabel.textColor = AppColor.danger
|
qrErrorLabel.textColor = AppColor.danger
|
||||||
@@ -73,19 +145,32 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
actionRow.axis = .horizontal
|
actionRow.axis = .horizontal
|
||||||
actionRow.alignment = .center
|
actionRow.alignment = .center
|
||||||
actionRow.spacing = AppSpacing.xl
|
actionRow.spacing = usesBranding ? AppSpacing.lg : AppSpacing.xl
|
||||||
|
if usesBranding {
|
||||||
|
actionRow.backgroundColor = UIColor.white.withAlphaComponent(0.88)
|
||||||
|
actionRow.layer.cornerRadius = 24
|
||||||
|
actionRow.isLayoutMarginsRelativeArrangement = true
|
||||||
|
actionRow.directionalLayoutMargins = NSDirectionalEdgeInsets(
|
||||||
|
top: 10,
|
||||||
|
leading: 24,
|
||||||
|
bottom: 10,
|
||||||
|
trailing: 24
|
||||||
|
)
|
||||||
|
}
|
||||||
|
actionRow.alpha = 0
|
||||||
|
actionRow.isUserInteractionEnabled = false
|
||||||
|
actionRow.accessibilityElementsHidden = true
|
||||||
|
|
||||||
configureLinkButton(setAmountButton, title: "设置金额")
|
configureLinkButton(setAmountButton, title: "设置金额")
|
||||||
configureLinkButton(saveQRButton, title: "保存二维码")
|
configureLinkButton(saveQRButton, title: "保存二维码")
|
||||||
|
|
||||||
infoCardView.backgroundColor = .white
|
configureCard(infoCardView, branded: usesBranding)
|
||||||
infoCardView.layer.cornerRadius = AppRadius.lg
|
|
||||||
|
|
||||||
recordRow.backgroundColor = .white
|
recordRow.backgroundColor = usesBranding ? .clear : .white
|
||||||
recordRow.layer.cornerRadius = AppRadius.lg
|
recordRow.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
|
||||||
|
|
||||||
voiceCardView.backgroundColor = .white
|
voiceCardView.backgroundColor = usesBranding ? .clear : .white
|
||||||
voiceCardView.layer.cornerRadius = AppRadius.lg
|
voiceCardView.layer.cornerRadius = usesBranding ? 0 : AppRadius.lg
|
||||||
|
|
||||||
voiceTitleLabel.text = "收款到账语音提醒"
|
voiceTitleLabel.text = "收款到账语音提醒"
|
||||||
voiceTitleLabel.font = .systemFont(ofSize: 14)
|
voiceTitleLabel.font = .systemFont(ofSize: 14)
|
||||||
@@ -93,49 +178,118 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
voiceSwitch.onTintColor = AppColor.primary
|
voiceSwitch.onTintColor = AppColor.primary
|
||||||
|
|
||||||
view.addSubview(scrollView)
|
if usesBranding {
|
||||||
scrollView.addSubview(contentStack)
|
configureBrandHeader()
|
||||||
|
backgroundImageView.contentMode = .scaleAspectFill
|
||||||
|
backgroundImageView.clipsToBounds = true
|
||||||
|
backgroundImageView.image = UIImage(named: "payment_nalati_background")
|
||||||
|
view.addSubview(backgroundImageView)
|
||||||
|
}
|
||||||
|
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(contentContainerView)
|
||||||
|
contentContainerView.addSubview(contentStack)
|
||||||
|
|
||||||
|
if usesBranding {
|
||||||
|
contentStack.addArrangedSubview(brandHeaderStack)
|
||||||
|
contentStack.setCustomSpacing(brandHeaderToQRSpacing, after: brandHeaderStack)
|
||||||
|
}
|
||||||
contentStack.addArrangedSubview(qrCardView)
|
contentStack.addArrangedSubview(qrCardView)
|
||||||
contentStack.addArrangedSubview(infoCardView)
|
contentStack.addArrangedSubview(infoCardView)
|
||||||
|
if usesBranding {
|
||||||
|
configureCard(settingsCardView, branded: true)
|
||||||
|
contentStack.addArrangedSubview(settingsCardView)
|
||||||
|
} else {
|
||||||
contentStack.addArrangedSubview(recordRow)
|
contentStack.addArrangedSubview(recordRow)
|
||||||
contentStack.addArrangedSubview(voiceCardView)
|
contentStack.addArrangedSubview(voiceCardView)
|
||||||
|
}
|
||||||
|
// 线下收款与原有真实收款功能并列,不替换二维码或收款记录入口。
|
||||||
|
contentStack.addArrangedSubview(offlineCollectionView)
|
||||||
|
|
||||||
|
let qrDisplayView = usesBranding ? qrContainerView : qrImageView
|
||||||
let qrContentStack = UIStackView(arrangedSubviews: [
|
let qrContentStack = UIStackView(arrangedSubviews: [
|
||||||
scenicNameLabel,
|
scenicNameLabel,
|
||||||
qrImageView,
|
qrDisplayView,
|
||||||
qrPlaceholderView,
|
qrPlaceholderView,
|
||||||
actionRow,
|
actionRow,
|
||||||
])
|
])
|
||||||
qrContentStack.axis = .vertical
|
qrContentStack.axis = .vertical
|
||||||
qrContentStack.alignment = .center
|
qrContentStack.alignment = .center
|
||||||
qrContentStack.spacing = AppSpacing.lg
|
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
|
||||||
qrCardView.addSubview(qrContentStack)
|
qrCardView.addSubview(qrContentStack)
|
||||||
|
|
||||||
|
qrPlaceholderView.addSubview(qrLoadingIndicator)
|
||||||
qrPlaceholderView.addSubview(qrErrorLabel)
|
qrPlaceholderView.addSubview(qrErrorLabel)
|
||||||
qrPlaceholderView.addSubview(refreshButton)
|
qrPlaceholderView.addSubview(refreshButton)
|
||||||
|
|
||||||
actionRow.addArrangedSubview(setAmountButton)
|
actionRow.addArrangedSubview(setAmountButton)
|
||||||
|
if usesBranding {
|
||||||
|
let divider = UIView()
|
||||||
|
divider.backgroundColor = UIColor(hex: 0xD6E1EE)
|
||||||
|
actionRow.addArrangedSubview(divider)
|
||||||
|
divider.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(1)
|
||||||
|
make.height.equalTo(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
actionRow.addArrangedSubview(saveQRButton)
|
actionRow.addArrangedSubview(saveQRButton)
|
||||||
|
|
||||||
let infoStack = UIStackView(arrangedSubviews: [scenicRow, merchantRow, staffRow])
|
let infoRows = usesBranding
|
||||||
|
? [photographerRow, storeRow]
|
||||||
|
: [scenicRow, merchantRow, staffRow]
|
||||||
|
let infoStack = UIStackView(arrangedSubviews: infoRows)
|
||||||
infoStack.axis = .vertical
|
infoStack.axis = .vertical
|
||||||
infoCardView.addSubview(infoStack)
|
infoCardView.addSubview(infoStack)
|
||||||
|
|
||||||
voiceCardView.addSubview(voiceTitleLabel)
|
voiceCardView.addSubview(voiceTitleLabel)
|
||||||
voiceCardView.addSubview(voiceSwitch)
|
voiceCardView.addSubview(voiceSwitch)
|
||||||
|
|
||||||
qrContentStack.snp.makeConstraints { make in
|
if usesBranding {
|
||||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 32, left: 16, bottom: 32, right: 16))
|
let settingsStack = UIStackView(arrangedSubviews: [recordRow, voiceCardView])
|
||||||
|
settingsStack.axis = .vertical
|
||||||
|
settingsCardView.addSubview(settingsStack)
|
||||||
|
settingsStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let divider = UIView()
|
||||||
|
divider.backgroundColor = UIColor(hex: 0xD9E2EA).withAlphaComponent(0.75)
|
||||||
|
settingsCardView.addSubview(divider)
|
||||||
|
divider.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.height.equalTo(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qrContentStack.snp.makeConstraints { make in
|
||||||
|
let verticalInset: CGFloat = usesBranding ? 0 : 32
|
||||||
|
make.edges.equalToSuperview().inset(
|
||||||
|
UIEdgeInsets(top: verticalInset, left: 16, bottom: verticalInset, right: 16)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scenicNameLabel.isHidden = usesBranding
|
||||||
scenicNameLabel.snp.makeConstraints { make in
|
scenicNameLabel.snp.makeConstraints { make in
|
||||||
make.leading.trailing.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
}
|
}
|
||||||
|
if usesBranding {
|
||||||
|
qrContainerView.snp.makeConstraints { make in
|
||||||
|
make.width.height.equalTo(brandQRContainerSize)
|
||||||
|
}
|
||||||
|
qrImageView.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.width.height.equalTo(brandQRImageSize)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
qrImageView.snp.makeConstraints { make in
|
qrImageView.snp.makeConstraints { make in
|
||||||
make.width.height.equalTo(182)
|
make.width.height.equalTo(182)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
qrPlaceholderView.snp.makeConstraints { make in
|
qrPlaceholderView.snp.makeConstraints { make in
|
||||||
make.width.height.equalTo(182)
|
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
|
||||||
|
}
|
||||||
|
qrLoadingIndicator.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
}
|
}
|
||||||
qrErrorLabel.snp.makeConstraints { make in
|
qrErrorLabel.snp.makeConstraints { make in
|
||||||
make.top.equalToSuperview().offset(56)
|
make.top.equalToSuperview().offset(56)
|
||||||
@@ -162,12 +316,35 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
|
if viewModel.usesNalatiBranding {
|
||||||
|
backgroundImageView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
scrollView.snp.makeConstraints { make in
|
scrollView.snp.makeConstraints { make in
|
||||||
|
if viewModel.usesNalatiBranding {
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(-44)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
} else {
|
||||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
contentContainerView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
make.width.equalTo(scrollView.snp.width)
|
||||||
|
if viewModel.usesNalatiBranding {
|
||||||
|
make.height.greaterThanOrEqualTo(scrollView.snp.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
contentStack.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
|
||||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2)
|
||||||
|
if viewModel.usesNalatiBranding {
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.top.bottom.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
} else {
|
||||||
|
make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +355,18 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
viewModel.onShowMessage = { [weak self] message in
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
Task { @MainActor in self?.showToast(message) }
|
Task { @MainActor in self?.showToast(message) }
|
||||||
}
|
}
|
||||||
|
offlineCollectionViewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyOfflineCollection() }
|
||||||
|
}
|
||||||
|
offlineCollectionView.onRegister = { [weak self] in
|
||||||
|
self?.openOfflineCollectionRegistration()
|
||||||
|
}
|
||||||
|
offlineCollectionView.onOpenToday = { [weak self] in
|
||||||
|
self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.todayBusinessDate)
|
||||||
|
}
|
||||||
|
offlineCollectionView.onOpenOverdue = { [weak self] in
|
||||||
|
self?.openOfflineCollectionDaily(date: self?.offlineCollectionViewModel.earliestOverdueBusinessDate)
|
||||||
|
}
|
||||||
|
|
||||||
setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside)
|
setAmountButton.addTarget(self, action: #selector(setAmountTapped), for: .touchUpInside)
|
||||||
saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside)
|
saveQRButton.addTarget(self, action: #selector(saveQRTapped), for: .touchUpInside)
|
||||||
@@ -188,13 +377,29 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
applyViewModel()
|
||||||
Task { await loadData() }
|
Task { await loadData() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
applyBrandNavigationAppearanceIfNeeded()
|
||||||
|
offlineCollectionViewModel.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
restoreNavigationAppearanceIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
viewModel.usesNalatiBranding ? .darkContent : .default
|
||||||
|
}
|
||||||
|
|
||||||
private func loadData() async {
|
private func loadData() async {
|
||||||
showLoading()
|
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
|
||||||
await viewModel.loadPayCode(api: paymentAPI)
|
await viewModel.loadPayCode(api: paymentAPI)
|
||||||
hideLoading()
|
await pageConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -203,17 +408,38 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
scenicRow.setValue(viewModel.displayScenicName)
|
scenicRow.setValue(viewModel.displayScenicName)
|
||||||
merchantRow.setValue(viewModel.merchantInfo.company)
|
merchantRow.setValue(viewModel.merchantInfo.company)
|
||||||
staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId)
|
staffRow.setValue(viewModel.staffId.isEmpty ? "-" : viewModel.staffId)
|
||||||
|
photographerRow.setValue(viewModel.displayPhotographerName)
|
||||||
|
storeRow.setValue(viewModel.displayStoreNames)
|
||||||
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
|
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
|
||||||
|
applyBrandConfigIfNeeded()
|
||||||
|
|
||||||
if let image = viewModel.qrImage {
|
if viewModel.isPayCodeLoading {
|
||||||
|
qrImageView.isHidden = true
|
||||||
|
qrContainerView.isHidden = true
|
||||||
|
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
|
||||||
|
? .white
|
||||||
|
: AppColor.inputBackground
|
||||||
|
qrPlaceholderView.isHidden = false
|
||||||
|
qrErrorLabel.isHidden = true
|
||||||
|
refreshButton.isHidden = true
|
||||||
|
qrLoadingIndicator.startAnimating()
|
||||||
|
setActionRowAvailable(false)
|
||||||
|
} else if let image = viewModel.qrImage {
|
||||||
qrImageView.image = image
|
qrImageView.image = image
|
||||||
qrImageView.isHidden = false
|
qrImageView.isHidden = false
|
||||||
|
qrContainerView.isHidden = !viewModel.usesNalatiBranding
|
||||||
qrPlaceholderView.isHidden = true
|
qrPlaceholderView.isHidden = true
|
||||||
actionRow.isHidden = false
|
qrLoadingIndicator.stopAnimating()
|
||||||
|
setActionRowAvailable(true)
|
||||||
} else {
|
} else {
|
||||||
qrImageView.isHidden = true
|
qrImageView.isHidden = true
|
||||||
|
qrContainerView.isHidden = true
|
||||||
|
qrPlaceholderView.backgroundColor = AppColor.inputBackground
|
||||||
qrPlaceholderView.isHidden = false
|
qrPlaceholderView.isHidden = false
|
||||||
actionRow.isHidden = true
|
qrErrorLabel.isHidden = false
|
||||||
|
refreshButton.isHidden = false
|
||||||
|
qrLoadingIndicator.stopAnimating()
|
||||||
|
setActionRowAvailable(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.showAmountDialog {
|
if viewModel.showAmountDialog {
|
||||||
@@ -222,6 +448,23 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
amountDialog?.dismiss()
|
amountDialog?.dismiss()
|
||||||
amountDialog = nil
|
amountDialog = nil
|
||||||
}
|
}
|
||||||
|
applyOfflineCollection()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyOfflineCollection() {
|
||||||
|
offlineCollectionView.apply(
|
||||||
|
today: offlineCollectionViewModel.todaySummary,
|
||||||
|
overdueDayCount: offlineCollectionViewModel.overdueSummaries.count,
|
||||||
|
overdueRecordCount: offlineCollectionViewModel.overdueRecordCount,
|
||||||
|
overdueAmountFen: offlineCollectionViewModel.overdueAmountFen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setActionRowAvailable(_ isAvailable: Bool) {
|
||||||
|
actionRow.alpha = isAvailable ? 1 : 0
|
||||||
|
actionRow.isUserInteractionEnabled = isAvailable
|
||||||
|
actionRow.accessibilityElementsHidden = !isAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentAmountDialogIfNeeded() {
|
private func presentAmountDialogIfNeeded() {
|
||||||
@@ -254,6 +497,181 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
button.titleLabel?.font = .systemFont(ofSize: 14)
|
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureCard(_ view: UIView, branded: Bool) {
|
||||||
|
view.backgroundColor = branded ? UIColor.white.withAlphaComponent(0.9) : .white
|
||||||
|
view.layer.cornerRadius = branded ? 18 : AppRadius.lg
|
||||||
|
view.clipsToBounds = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBrandHeader() {
|
||||||
|
brandHeaderStack.axis = .vertical
|
||||||
|
brandHeaderStack.alignment = .center
|
||||||
|
brandHeaderStack.spacing = brandScreenHeight <= 700 ? 6 : 8
|
||||||
|
|
||||||
|
brandLogoImageView.image = UIImage(named: "payment_nalati_logo")
|
||||||
|
brandLogoImageView.contentMode = .scaleAspectFit
|
||||||
|
brandLogoImageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
|
brandTitleLabel.font = .systemFont(
|
||||||
|
ofSize: brandScreenHeight <= 700 ? 21 : 23,
|
||||||
|
weight: .bold
|
||||||
|
)
|
||||||
|
brandTitleLabel.textColor = UIColor(hex: 0x102D54)
|
||||||
|
brandTitleLabel.textAlignment = .center
|
||||||
|
brandTitleLabel.numberOfLines = 1
|
||||||
|
brandTitleLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
brandTitleLabel.minimumScaleFactor = 0.8
|
||||||
|
|
||||||
|
brandSubtitleLabel.font = .systemFont(
|
||||||
|
ofSize: brandScreenHeight <= 700 ? 15 : 16,
|
||||||
|
weight: .medium
|
||||||
|
)
|
||||||
|
brandSubtitleLabel.textColor = UIColor(hex: 0x183A61)
|
||||||
|
brandSubtitleLabel.textAlignment = .center
|
||||||
|
|
||||||
|
let subtitleRow = UIStackView()
|
||||||
|
subtitleRow.axis = .horizontal
|
||||||
|
subtitleRow.alignment = .center
|
||||||
|
subtitleRow.spacing = 9
|
||||||
|
|
||||||
|
let leftLine = makeBrandLine()
|
||||||
|
let leftDiamond = makeBrandDiamond()
|
||||||
|
let rightDiamond = makeBrandDiamond()
|
||||||
|
let rightLine = makeBrandLine()
|
||||||
|
[leftLine, leftDiamond, brandSubtitleLabel, rightDiamond, rightLine].forEach(
|
||||||
|
subtitleRow.addArrangedSubview
|
||||||
|
)
|
||||||
|
|
||||||
|
brandHeaderStack.addArrangedSubview(brandLogoImageView)
|
||||||
|
brandHeaderStack.addArrangedSubview(brandTitleLabel)
|
||||||
|
brandHeaderStack.addArrangedSubview(subtitleRow)
|
||||||
|
brandHeaderStack.setCustomSpacing(
|
||||||
|
brandScreenHeight <= 700 ? 8 : 10,
|
||||||
|
after: brandLogoImageView
|
||||||
|
)
|
||||||
|
|
||||||
|
brandLogoImageView.snp.makeConstraints { make in
|
||||||
|
make.width.equalToSuperview().inset(8)
|
||||||
|
make.height.equalTo(brandLogoHeight)
|
||||||
|
}
|
||||||
|
brandTitleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(8)
|
||||||
|
}
|
||||||
|
[leftLine, rightLine].forEach { line in
|
||||||
|
line.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(42)
|
||||||
|
make.height.equalTo(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[leftDiamond, rightDiamond].forEach { diamond in
|
||||||
|
diamond.snp.makeConstraints { make in
|
||||||
|
make.width.height.equalTo(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeBrandLine() -> UIView {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = UIColor(hex: 0x27496D).withAlphaComponent(0.75)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeBrandDiamond() -> UIView {
|
||||||
|
let view = UIView()
|
||||||
|
view.backgroundColor = UIColor(hex: 0x27496D)
|
||||||
|
view.transform = CGAffineTransform(rotationAngle: .pi / 4)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyBrandConfigIfNeeded() {
|
||||||
|
guard viewModel.usesNalatiBranding else { return }
|
||||||
|
let config = viewModel.payPageConfig
|
||||||
|
brandTitleLabel.text = config.title
|
||||||
|
brandSubtitleLabel.text = config.subtitle
|
||||||
|
|
||||||
|
let versionChanged = appliedBrandingRefreshVersion != viewModel.brandingRefreshVersion
|
||||||
|
guard appliedBrandConfig != config || versionChanged else { return }
|
||||||
|
let forceRefresh = appliedBrandingRefreshVersion >= 0 && versionChanged
|
||||||
|
setBrandImage(
|
||||||
|
backgroundImageView,
|
||||||
|
urlString: config.background,
|
||||||
|
fallbackName: "payment_nalati_background",
|
||||||
|
forceRefresh: forceRefresh
|
||||||
|
)
|
||||||
|
setBrandImage(
|
||||||
|
brandLogoImageView,
|
||||||
|
urlString: config.logo,
|
||||||
|
fallbackName: "payment_nalati_logo",
|
||||||
|
forceRefresh: forceRefresh
|
||||||
|
)
|
||||||
|
appliedBrandConfig = config
|
||||||
|
appliedBrandingRefreshVersion = viewModel.brandingRefreshVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setBrandImage(
|
||||||
|
_ imageView: UIImageView,
|
||||||
|
urlString: String,
|
||||||
|
fallbackName: String,
|
||||||
|
forceRefresh: Bool
|
||||||
|
) {
|
||||||
|
let fallback = UIImage(named: fallbackName)
|
||||||
|
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let url = URL(string: trimmed),
|
||||||
|
url.scheme?.lowercased() == "https",
|
||||||
|
url.host != nil else {
|
||||||
|
imageView.image = fallback
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var options: KingfisherOptionsInfo = [
|
||||||
|
.keepCurrentImageWhileLoading,
|
||||||
|
.cacheOriginalImage,
|
||||||
|
.transition(.fade(0.2)),
|
||||||
|
]
|
||||||
|
if forceRefresh {
|
||||||
|
options.append(.forceRefresh)
|
||||||
|
}
|
||||||
|
imageView.kf.setImage(with: url, placeholder: fallback, options: options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyBrandNavigationAppearanceIfNeeded() {
|
||||||
|
guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return }
|
||||||
|
previousStandardAppearance = navigationBar.standardAppearance
|
||||||
|
previousScrollEdgeAppearance = navigationBar.scrollEdgeAppearance
|
||||||
|
previousCompactAppearance = navigationBar.compactAppearance
|
||||||
|
previousNavigationTintColor = navigationBar.tintColor
|
||||||
|
previousNavigationTranslucency = navigationBar.isTranslucent
|
||||||
|
|
||||||
|
let appearance = UINavigationBarAppearance()
|
||||||
|
appearance.configureWithTransparentBackground()
|
||||||
|
appearance.shadowColor = .clear
|
||||||
|
appearance.titleTextAttributes = [
|
||||||
|
.foregroundColor: UIColor(hex: 0x111827),
|
||||||
|
.font: UIFont.app(.title),
|
||||||
|
]
|
||||||
|
navigationBar.standardAppearance = appearance
|
||||||
|
navigationBar.scrollEdgeAppearance = appearance
|
||||||
|
navigationBar.compactAppearance = appearance
|
||||||
|
navigationBar.tintColor = UIColor(hex: 0x111827)
|
||||||
|
navigationBar.isTranslucent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreNavigationAppearanceIfNeeded() {
|
||||||
|
guard viewModel.usesNalatiBranding, let navigationBar = navigationController?.navigationBar else { return }
|
||||||
|
if let previousStandardAppearance {
|
||||||
|
navigationBar.standardAppearance = previousStandardAppearance
|
||||||
|
}
|
||||||
|
navigationBar.scrollEdgeAppearance = previousScrollEdgeAppearance
|
||||||
|
navigationBar.compactAppearance = previousCompactAppearance
|
||||||
|
navigationBar.tintColor = previousNavigationTintColor
|
||||||
|
navigationBar.isTranslucent = previousNavigationTranslucency
|
||||||
|
previousStandardAppearance = nil
|
||||||
|
previousScrollEdgeAppearance = nil
|
||||||
|
previousCompactAppearance = nil
|
||||||
|
previousNavigationTintColor = nil
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func setAmountTapped() {
|
@objc private func setAmountTapped() {
|
||||||
viewModel.showSetAmountDialog()
|
viewModel.showSetAmountDialog()
|
||||||
}
|
}
|
||||||
@@ -266,9 +684,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
|
|
||||||
@objc private func refreshTapped() {
|
@objc private func refreshTapped() {
|
||||||
Task {
|
Task {
|
||||||
showLoading()
|
|
||||||
await viewModel.refresh(api: paymentAPI)
|
await viewModel.refresh(api: paymentAPI)
|
||||||
hideLoading()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +695,24 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
|
|||||||
@objc private func voiceSwitchChanged() {
|
@objc private func voiceSwitchChanged() {
|
||||||
viewModel.toggleReceiveVoice()
|
viewModel.toggleReceiveVoice()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func openOfflineCollectionRegistration() {
|
||||||
|
let controller = OfflineCollectionRegistrationViewController(
|
||||||
|
context: offlineCollectionViewModel.context,
|
||||||
|
service: offlineCollectionViewModel.service
|
||||||
|
)
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openOfflineCollectionDaily(date: String?) {
|
||||||
|
guard let date else { return }
|
||||||
|
let controller = OfflineCollectionDailyViewController(
|
||||||
|
businessDate: date,
|
||||||
|
context: offlineCollectionViewModel.context,
|
||||||
|
service: offlineCollectionViewModel.service
|
||||||
|
)
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 收款详情信息行。
|
/// 收款详情信息行。
|
||||||
@@ -310,7 +744,7 @@ private final class PaymentInfoRowView: UIView {
|
|||||||
valueLabel.font = .systemFont(ofSize: 14)
|
valueLabel.font = .systemFont(ofSize: 14)
|
||||||
valueLabel.textColor = AppColor.textPrimary
|
valueLabel.textColor = AppColor.textPrimary
|
||||||
valueLabel.textAlignment = .right
|
valueLabel.textAlignment = .right
|
||||||
valueLabel.numberOfLines = 2
|
valueLabel.numberOfLines = 0
|
||||||
|
|
||||||
divider.backgroundColor = UIColor(hex: 0xE5E7EB)
|
divider.backgroundColor = UIColor(hex: 0xE5E7EB)
|
||||||
|
|
||||||
|
|||||||