Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca4bd5a20 | ||
|
|
eb57666033 | ||
|
|
2c4123214d | ||
|
|
8cd6c0aac8 | ||
|
|
1757ba5e36 | ||
|
|
c34c4e1923 | ||
|
|
b127495e52 | ||
|
|
76aa7bb4aa | ||
|
|
8e444f7434 | ||
|
|
83afcb6deb | ||
|
|
30bfe0313f | ||
|
|
0f3b26991e | ||
|
|
e190b5958b | ||
|
|
6e2899dbfd | ||
|
|
48b6b9be22 | ||
|
|
202b0f5e41 | ||
|
|
7b9e630838 | ||
|
|
109ec3dad0 | ||
|
|
f2a005e008 | ||
|
|
60502784db | ||
|
|
05cfdc3662 | ||
|
|
010add828b | ||
|
|
aca19a53fb | ||
|
|
e64dcec094 | ||
|
|
43c75c8e36 | ||
|
|
5704531f3a | ||
|
|
87819780c9 | ||
|
|
3b88b9cde9 | ||
|
|
439edf827c | ||
|
|
24fa66281c | ||
|
|
99fbad3e97 | ||
|
|
dcdab3cff7 | ||
|
|
1cdce616e4 | ||
|
|
212607fc82 | ||
|
|
0cf0a41a60 | ||
|
|
f863004c8d | ||
|
|
61c56c7adb | ||
|
|
413a9603a4 | ||
|
|
f87b13cc05 | ||
|
|
0e0415eec4 | ||
|
|
7ccbc5568d | ||
|
|
787a166263 | ||
|
|
74f7370aab | ||
|
|
1b2637f0ee |
@@ -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,282 @@
|
|||||||
|
# AI 重新修图接口优化需求
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
图片预览页面支持用户在“原图”Tab 上重新选择修图模板。
|
||||||
|
|
||||||
|
用户可能只生成过一种关联图片,例如之前仅生成了“原图精修”,尚未生成“氛围感”图片。此时用户重新修图时,可以同时选择:
|
||||||
|
|
||||||
|
- 原图精修模板
|
||||||
|
- 氛围感修图模板
|
||||||
|
|
||||||
|
客户端会调用现有 `ai-reretouch` 接口,并通过 `type = 3` 提交两个模板,希望一次创建两个修图输出任务。
|
||||||
|
|
||||||
|
## 2. 问题复现
|
||||||
|
|
||||||
|
### 前置状态
|
||||||
|
|
||||||
|
- 原图素材 ID:`702`
|
||||||
|
- 原 AI 修图批次 ID:`61`
|
||||||
|
- 已存在并完成:原图精修
|
||||||
|
- 从未生成:氛围感图片
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/yf-handset-app/photog/travel-album/ai-reretouch
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ai_retouch_batch_id": 61,
|
||||||
|
"id": 702,
|
||||||
|
"type": 3,
|
||||||
|
"refined_template_id": 2,
|
||||||
|
"atmosphere_template_id": 14
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段含义:
|
||||||
|
|
||||||
|
| 字段 | 值 | 说明 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `ai_retouch_batch_id` | `61` | 原 AI 修图批次 ID |
|
||||||
|
| `id` | `702` | 原图素材 ID |
|
||||||
|
| `type` | `3` | 同时处理精修和氛围感 |
|
||||||
|
| `refined_template_id` | `2` | 本次选择的精修模板 |
|
||||||
|
| `atmosphere_template_id` | `14` | 本次选择的氛围感模板 |
|
||||||
|
|
||||||
|
### 当前响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100099,
|
||||||
|
"data": [],
|
||||||
|
"msg": "仅已完成修图的照片可重新修图",
|
||||||
|
"time": "2026-08-14 17:41:17"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 当前问题
|
||||||
|
|
||||||
|
从响应判断,后端可能在 `type = 3` 时要求精修图和氛围感图都已经存在且完成,才允许重新修图。
|
||||||
|
|
||||||
|
但本场景中:
|
||||||
|
|
||||||
|
- 精修图已经存在,本次需要重新生成,并在成功后覆盖旧精修图。
|
||||||
|
- 氛围感图从未生成,本次需要创建新的关联图片。
|
||||||
|
|
||||||
|
由于氛围感图历史上不存在,接口拒绝了整个请求,导致客户端无法通过一次请求同时完成“覆盖已有精修图”和“新增氛围感图”。
|
||||||
|
|
||||||
|
客户端当前请求已经正确传递 `type = 3` 和两个模板 ID,无需修改请求结构。
|
||||||
|
|
||||||
|
## 4. 期望接口行为
|
||||||
|
|
||||||
|
建议将 `ai-reretouch` 调整为按输出类型分别执行的 **upsert(存在则覆盖,不存在则新增)** 语义。
|
||||||
|
|
||||||
|
对于本次请求:
|
||||||
|
|
||||||
|
### 原图精修
|
||||||
|
|
||||||
|
- 请求传入了 `refined_template_id`。
|
||||||
|
- 旧精修图已经存在。
|
||||||
|
- 后端创建新的精修任务。
|
||||||
|
- 新任务成功后覆盖旧精修图。
|
||||||
|
- 新任务处理中或失败时保留旧精修图。
|
||||||
|
|
||||||
|
### 氛围感修图
|
||||||
|
|
||||||
|
- 请求传入了 `atmosphere_template_id`。
|
||||||
|
- 旧氛围感图不存在。
|
||||||
|
- 后端创建新的氛围感任务。
|
||||||
|
- 任务成功后,将结果保存为原图的新关联图片。
|
||||||
|
|
||||||
|
### 整体任务
|
||||||
|
|
||||||
|
- 一次请求创建两个输出任务:`refined` 和 `atmosphere`。
|
||||||
|
- 按两个实际创建的输出预占 2 张修图额度。
|
||||||
|
- 两个输出分别执行、分别记录状态、分别结算额度。
|
||||||
|
- 单个输出失败不应影响另一个已经成功的输出。
|
||||||
|
|
||||||
|
## 5. 建议的后端处理规则
|
||||||
|
|
||||||
|
### 5.1 基础校验
|
||||||
|
|
||||||
|
建议校验:
|
||||||
|
|
||||||
|
- 原图素材存在。
|
||||||
|
- 原图素材属于指定相册或批次。
|
||||||
|
- 原图当前状态允许提交 AI 修图任务。
|
||||||
|
- 请求中至少传入一个与 `type` 匹配的有效模板 ID。
|
||||||
|
- 用户剩余额度满足本次实际创建的输出数量。
|
||||||
|
|
||||||
|
不建议校验:
|
||||||
|
|
||||||
|
- `type = 3` 时要求精修和氛围感历史结果都必须存在。
|
||||||
|
- 某一种关联图从未生成时直接拒绝整个请求。
|
||||||
|
|
||||||
|
### 5.2 按模板字段创建任务
|
||||||
|
|
||||||
|
后端根据本次实际传入的模板字段创建对应任务:
|
||||||
|
|
||||||
|
| 请求情况 | 期望处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 只传 `refined_template_id` | 只创建精修任务 |
|
||||||
|
| 只传 `atmosphere_template_id` | 只创建氛围感任务 |
|
||||||
|
| 两个模板 ID 都传 | 同时创建精修和氛围感任务 |
|
||||||
|
| 某个模板 ID 未传 | 不处理、不覆盖该类型的已有结果 |
|
||||||
|
|
||||||
|
### 5.3 按输出类型处理已有结果
|
||||||
|
|
||||||
|
对于每个实际提交的输出类型,分别判断:
|
||||||
|
|
||||||
|
| 历史状态 | 建议处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| 已存在成功结果 | 创建重修任务,新结果成功后替换旧结果 |
|
||||||
|
| 从未生成 | 创建新任务,成功后新增关联图片 |
|
||||||
|
| 历史任务失败或已取消 | 允许重新创建任务 |
|
||||||
|
| 当前已有处理中或排队任务 | 拒绝该类型重复提交,或通过幂等机制复用已有任务 |
|
||||||
|
|
||||||
|
### 5.4 替换时机
|
||||||
|
|
||||||
|
继续采用现有 `replace_on_success` 语义:
|
||||||
|
|
||||||
|
- 新结果成功后,才替换同类型旧结果。
|
||||||
|
- 新任务排队、处理中或失败时,保留旧结果。
|
||||||
|
- 精修和氛围感分别替换,互不影响。
|
||||||
|
|
||||||
|
## 6. `type` 参数建议语义
|
||||||
|
|
||||||
|
保持现有请求类型和接口路径不变:
|
||||||
|
|
||||||
|
| `type` | 语义 | 模板规则 |
|
||||||
|
| ---: | --- | --- |
|
||||||
|
| `1` | 只处理精修 | 必须传 `refined_template_id`,忽略氛围感模板 |
|
||||||
|
| `2` | 只处理氛围感 | 必须传 `atmosphere_template_id`,忽略精修模板 |
|
||||||
|
| `3` | 同时处理精修和氛围感 | 根据实际传入的两个模板分别创建任务 |
|
||||||
|
|
||||||
|
对于 `type = 3`:
|
||||||
|
|
||||||
|
- 必须传 `refined_template_id`。
|
||||||
|
- `atmosphere_template_id` 可以选传。
|
||||||
|
- 传入氛围感模板时创建氛围感任务。
|
||||||
|
- 未传氛围感模板时,只重修精修图,并保留已有氛围感图。
|
||||||
|
|
||||||
|
## 7. 建议处理伪代码
|
||||||
|
|
||||||
|
```text
|
||||||
|
validateOriginalMaterial(request.id)
|
||||||
|
validateBatch(request.ai_retouch_batch_id)
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
|
||||||
|
if request.refined_template_id is not null:
|
||||||
|
outputs.add(
|
||||||
|
createTask(
|
||||||
|
outputType = refined,
|
||||||
|
templateId = request.refined_template_id,
|
||||||
|
mode = existingRefinedResult ? replace_on_success : create
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.atmosphere_template_id is not null:
|
||||||
|
outputs.add(
|
||||||
|
createTask(
|
||||||
|
outputType = atmosphere,
|
||||||
|
templateId = request.atmosphere_template_id,
|
||||||
|
mode = existingAtmosphereResult ? replace_on_success : create
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if outputs is empty:
|
||||||
|
return invalid_template_error
|
||||||
|
|
||||||
|
reserveQuota(outputs.count)
|
||||||
|
submitTasks(outputs)
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:实际是否存在旧结果,应按照各自的 `output_type` 独立查询,不能用“两个类型都已完成”作为 `type = 3` 的整体前置条件。
|
||||||
|
|
||||||
|
## 8. 期望响应
|
||||||
|
|
||||||
|
接口成功响应建议继续返回任务批次信息,并明确本次创建的输出类型。例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 61,
|
||||||
|
"source_material_id": 702,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"mode": "replace_on_success",
|
||||||
|
"status": "queued"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"mode": "create",
|
||||||
|
"status": "queued"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reserved_units": 2
|
||||||
|
},
|
||||||
|
"msg": "AI修图任务已提交"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应结构可沿用现有实现,不强制增加上述字段;关键要求是一次请求能够成功创建两个对应的修图任务。
|
||||||
|
|
||||||
|
## 9. 验收用例
|
||||||
|
|
||||||
|
### 用例一:只有旧精修图,同时提交两个模板
|
||||||
|
|
||||||
|
- 历史状态:精修已完成,氛围感不存在。
|
||||||
|
- 请求:`type = 3`,同时传精修和氛围感模板。
|
||||||
|
- 期望:创建两个任务;精修成功后覆盖,氛围感成功后新增。
|
||||||
|
|
||||||
|
### 用例二:只有旧氛围感图,同时提交两个模板
|
||||||
|
|
||||||
|
- 历史状态:精修不存在,氛围感已完成。
|
||||||
|
- 请求:`type = 3`,同时传精修和氛围感模板。
|
||||||
|
- 期望:创建两个任务;精修成功后新增,氛围感成功后覆盖。
|
||||||
|
|
||||||
|
### 用例三:两种旧结果都存在
|
||||||
|
|
||||||
|
- 历史状态:精修、氛围感都已完成。
|
||||||
|
- 请求:`type = 3`,同时传两个模板。
|
||||||
|
- 期望:创建两个任务,分别在成功后覆盖各自旧结果。
|
||||||
|
|
||||||
|
### 用例四:原图 Tab 只选择精修模板
|
||||||
|
|
||||||
|
- 历史状态:精修、氛围感均可能存在。
|
||||||
|
- 请求:`type = 3`,只传 `refined_template_id`。
|
||||||
|
- 期望:只创建精修任务;不处理、不删除、不覆盖旧氛围感图;预占 1 张额度。
|
||||||
|
|
||||||
|
### 用例五:精修后 Tab 重新修图
|
||||||
|
|
||||||
|
- 请求:`type = 1`,只传 `refined_template_id`。
|
||||||
|
- 期望:只创建精修任务,新精修成功后覆盖旧精修图。
|
||||||
|
|
||||||
|
### 用例六:氛围感 Tab 重新修图
|
||||||
|
|
||||||
|
- 请求:`type = 2`,只传 `atmosphere_template_id`。
|
||||||
|
- 期望:只创建氛围感任务,新氛围感成功后覆盖旧氛围感图。
|
||||||
|
|
||||||
|
### 用例七:其中一个输出失败
|
||||||
|
|
||||||
|
- 请求创建精修和氛围感两个任务。
|
||||||
|
- 精修成功,氛围感失败。
|
||||||
|
- 期望:保留成功的新精修结果;氛围感旧图存在时继续保留旧图,不存在时不生成关联图;额度分别结算。
|
||||||
|
|
||||||
|
### 用例八:防止重复提交
|
||||||
|
|
||||||
|
- 同一原图、同一输出类型已经存在排队中或处理中的任务。
|
||||||
|
- 用户再次提交相同任务。
|
||||||
|
- 期望:通过幂等机制返回原任务,或仅拒绝重复的输出类型,避免重复扣减额度和重复生成结果。
|
||||||
|
|
||||||
|
## 10. 总结
|
||||||
|
|
||||||
|
希望 `ai-reretouch` 支持以下统一语义:
|
||||||
|
|
||||||
|
> 对本次传入模板对应的每种输出独立处理:已有结果则在新任务成功后覆盖,没有结果则新增;未传模板的类型不处理。不要因为某一种关联图历史上从未生成,就拒绝整个重新修图请求。
|
||||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 7.8 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,85 @@
|
|||||||
|
# AI 修图完整链路设计稿 V1
|
||||||
|
|
||||||
|
## 交付内容
|
||||||
|
|
||||||
|
| 文件 | 页面 / 状态 |
|
||||||
|
|---|---|
|
||||||
|
| `00-flow-overview.png` | 六张关键屏稿总览 |
|
||||||
|
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张照片 |
|
||||||
|
| `02-template-selection.png` | AI 修图模板选择 Sheet |
|
||||||
|
| `03-template-preview.png` | 模板全屏预览 |
|
||||||
|
| `04-result-success.png` | 精修结果 · 成功 |
|
||||||
|
| `05-result-processing.png` | 精修结果 · 生成新版本中 |
|
||||||
|
| `06-result-failed.png` | 精修结果 · 新版本生成失败 |
|
||||||
|
|
||||||
|
独立屏稿均为 853 × 1844 像素(约 9:19.5),面向 iOS 16+、iPhone 竖屏、浅色模式。
|
||||||
|
|
||||||
|
## 设计系统
|
||||||
|
|
||||||
|
- 主色:`#1677FF`;辅助渐变:`#5CA8FF`、`#A7D8FF`。
|
||||||
|
- 页面底色:`#F7FAFF`;主文字:`#172033`;次文字:`#667085`。
|
||||||
|
- 失败色:`#EF4444`;赠送/免费标签使用橙色,避免与主要操作竞争。
|
||||||
|
- 字体:SF Pro / 苹方风格;圆角 12–16 pt;以 8 pt 为基础间距。
|
||||||
|
- 正文置于高不透明度表面;极光光晕只用于背景与重点操作,不承载关键信息。
|
||||||
|
- 结果页只通过“原图 / 精修后 / 氛围感”标签切换,不使用对比滑杆。
|
||||||
|
- 重新修图期间及失败后保留上一版成功图片;状态反馈只占局部,不遮挡照片。
|
||||||
|
|
||||||
|
## 可复用 ImageGen 提示词
|
||||||
|
|
||||||
|
以下提示词使用内置 ImageGen。首屏以 `../travel-album-management-redesign-v2.png` 为风格参考;后续页面以上一张已确认屏稿作为参考,确保视觉连续。
|
||||||
|
|
||||||
|
### 通用前缀
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: ui-mockup
|
||||||
|
Asset type: shippable high-fidelity iOS app screen, full portrait canvas, no device frame
|
||||||
|
Style/medium: realistic production UIKit UI, light Aurora blue, crisp SF Pro / PingFang-like typography, SF Symbols-style icons, 12–16 pt corners, 8 pt spacing grid, subtle borders and restrained shadows.
|
||||||
|
Color palette: primary #1677FF, gradient accents #5CA8FF and #A7D8FF, background #F7FAFF, text #172033 and #667085, danger #EF4444.
|
||||||
|
Constraints: iPhone portrait 9:19.5; iOS status bar and home indicator; practical UIKit-buildable hierarchy; accessible contrast; minimum 44 pt touch targets; no dark mode; no watermark; no decorative device frame; no annotations.
|
||||||
|
Text constraints: render specified Chinese text exactly once; no gibberish, duplicate labels or unrelated copy.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 01 · 相册多选
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create the album management screen in multi-selection mode. Top navigation: “相册管理” and “完成”. Album summary: “旅拍相册”, “138****8000”. Filter row: “全部 128”, “已购 36”, “时间 ↓”. Use a precise three-column grid of nine travel photos. Four photos have blue checkmarks, blue outlines and subtle selected overlays; other photos show empty selectors. Fixed bottom actions: red outline “删除 4 张” and blue gradient “AI修图 4 张”.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 02 · 模板选择
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create a large native page sheet titled “AI修图” with subtitle “已选择 4 张照片”. Show the note “每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面”. Include three horizontal sections: “原图精修” + “必选”, “氛围感修图” + “选填”, “封面风格” + “赠送 · 不占额度”. Each section has three photographic cards, a clear single selected state and “预览” buttons. Cost card: “AI精修 · 按张收费”, “预计消耗 8 张”, “剩余 12 张”. Fixed actions: “取消” and “确定”.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 03 · 模板预览
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create an immersive full-screen preview for the selected “自然通透” template. Use a dark navy photographic canvas, a circular back button, title “自然通透” and subtitle “双指缩放查看细节”. The large photo shows a woman in a straw hat overlooking Santorini. Add “模板效果预览”. Bottom information panel: “自然通透”, “提亮肤色与画面通透感,保留自然质感”, “当前已选择”. Primary action: “使用此模板”. Do not add a comparison slider.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 04 · 正常结果
|
||||||
|
|
||||||
|
```text
|
||||||
|
Create “照片预览” with counter “1 / 9”. Add exactly three tabs: “原图”, “精修后”, “氛围感”; select “精修后”. Show the same Santorini photo with badge “AI已修”, filename “IMG_8291.JPG” and “自然通透 · 2026/08/13”. Information card: “精修结果”, “自然通透模板 · 已生成最新版本”. Fixed actions: “AI修图”, “删除”, “刷新”. Use tabs only; no slider or press-and-hold comparison.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 05 · 处理中状态编辑
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with spinner + “修图中”. Replace the result card with “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, plus an indeterminate shimmer line with no percentage. Keep the previous photo unobscured. Disable only the “AI修图” button; leave “删除” and “刷新” available.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 06 · 失败状态编辑
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit. Keep the successful result screen, photo, tabs and layout unchanged. Replace “AI已修” with a local red badge “生成失败”. Replace the result card with pale-red recovery content: “新版本生成失败”, “上一版结果已保留,可重新修图”, and an outline action “重新修图”. Keep the previous photo unobscured and restore the bottom “AI修图” button to its primary blue state. Do not show a modal, stack trace or error code.
|
||||||
|
```
|
||||||
|
|
||||||
|
## QA 记录
|
||||||
|
|
||||||
|
- 六张独立屏稿尺寸一致,使用同一旅拍照片体系与组件语言。
|
||||||
|
- 精修必选、氛围感选填、四张照片触发封面、封面不占额度均已表达。
|
||||||
|
- 成功、处理中、失败三种状态均保留同一照片与结果标签,便于直接比较。
|
||||||
|
- 处理中不展示虚构百分比;重复提交按钮禁用,刷新仍可用。
|
||||||
|
- 失败状态提供“重新修图”恢复路径,且上一版结果保持可见。
|
||||||
|
- 本目录只包含设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
|
||||||
|
After Width: | Height: | Size: 8.0 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,76 @@
|
|||||||
|
# AI 修图完整链路设计稿 V2
|
||||||
|
|
||||||
|
## 本轮调整
|
||||||
|
|
||||||
|
- 第 1、2 张沿用 V1 的浅色“轻盈极光蓝”风格。
|
||||||
|
- 第 3–6 张改为统一的黑色专业影像工作区,突出照片本身。
|
||||||
|
- 暗色页面不做简单反色:背景、浮层、边框、文字和按钮均使用独立暗色语义层级。
|
||||||
|
- V1 完整保留在 `../ai-retouch-v1/`,便于对比和回退。
|
||||||
|
|
||||||
|
## 交付内容
|
||||||
|
|
||||||
|
| 文件 | 页面 / 状态 | 风格 |
|
||||||
|
|---|---|---|
|
||||||
|
| `00-flow-overview-v2.png` | 六张关键屏稿总览 | 混合流程 |
|
||||||
|
| `01-album-multi-selection.png` | 相册管理 · 已选择 4 张 | 浅色 |
|
||||||
|
| `02-template-selection.png` | AI 修图模板选择 Sheet | 浅色 |
|
||||||
|
| `03-template-preview-dark.png` | 模板全屏预览 | 暗色 |
|
||||||
|
| `04-result-success-dark.png` | 精修结果 · 成功 | 暗色 |
|
||||||
|
| `05-result-processing-dark.png` | 精修结果 · 生成中 | 暗色 |
|
||||||
|
| `06-result-failed-dark.png` | 精修结果 · 生成失败 | 暗色 |
|
||||||
|
|
||||||
|
## 暗色设计 Token
|
||||||
|
|
||||||
|
- 页面背景:`#05070D`,允许向深海军蓝 `#07111F` 做非常轻微的层次过渡。
|
||||||
|
- 抬升表面:`#111827`;边框:`#263244`。
|
||||||
|
- 主文字:`#F8FAFC`;次文字:`#9CA3AF`;未选中标签:`#C7CED9`。
|
||||||
|
- 品牌主色:`#1677FF`;蓝色高光:`#5CA8FF`。
|
||||||
|
- 失败色:`#EF4444`,仅用于失败徽标、恢复入口和局部边框。
|
||||||
|
- 图片保持明亮自然,不叠加全屏蒙层;信息区和操作区不出现白色表面。
|
||||||
|
|
||||||
|
## 可复用 ImageGen 暗色编辑提示词
|
||||||
|
|
||||||
|
以下提示词使用内置 ImageGen,以对应 V1 页面作为 edit target;第 3 张暗色稿确定后,也作为第 4–6 张的风格参考。
|
||||||
|
|
||||||
|
### 通用暗色编辑约束
|
||||||
|
|
||||||
|
```text
|
||||||
|
Use case: precise-object-edit
|
||||||
|
Restyle the target into a cohesive dark professional photography workspace.
|
||||||
|
Preserve the exact iPhone portrait dimensions, photo content and crop, person identity and pose, all Chinese copy, hierarchy, controls and safe-area spacing.
|
||||||
|
Use background #05070D, elevated surface #111827, border #263244, primary #1677FF, text #F8FAFC and secondary #9CA3AF.
|
||||||
|
Keep the bright photo unchanged as the focal point. Use white text and SF Symbols-style white icons. Use brand blue only for selected state and primary actions.
|
||||||
|
No white page areas, no comparison slider, no device frame, no annotations, no watermark and no additional copy.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模板预览
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keep “自然通透”, “双指缩放查看细节”, the Santorini photo and “模板效果预览”. Change the lower information panel to charcoal with white heading, muted description and blue “当前已选择”. Change the complete bottom safe area to black and retain the blue “使用此模板” action.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修图结果
|
||||||
|
|
||||||
|
```text
|
||||||
|
Keep “照片预览”, “1 / 9” and the three tabs “原图 / 精修后 / 氛围感”. Use a charcoal segmented control with “精修后” selected in blue. Keep “AI已修”, the filename metadata, the photo and the result information. Change the result card and secondary actions “删除 / 刷新” to dark surfaces; keep “AI修图” blue.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 处理中状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Based on the approved dark result screen, change only the badge to spinner + “修图中”. Keep the previous photo visible. Change the card to “正在生成新版本” and “当前仍展示上一版结果,完成后自动更新”, with an indeterminate shimmer line and no percentage. Disable only “AI修图”.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 失败状态
|
||||||
|
|
||||||
|
```text
|
||||||
|
Based on the approved dark result screen, change only the badge to “生成失败”. Keep the previous photo visible. Use a charcoal recovery card with restrained red border, “新版本生成失败”, “上一版结果已保留,可重新修图” and “重新修图”. Keep the main “AI修图” button blue. Do not use a white or pale-red card.
|
||||||
|
```
|
||||||
|
|
||||||
|
## QA 记录
|
||||||
|
|
||||||
|
- 第 1、2 张与 V1 像素级复用,没有重新生成。
|
||||||
|
- 第 3–6 张均为 853 × 1844,暗色背景、表面、文字和操作层级一致。
|
||||||
|
- 成功、处理中、失败三态保留同一结果标签、照片和底部操作布局。
|
||||||
|
- 处理中无虚构百分比;失败状态有明确恢复动作;旧结果始终可见。
|
||||||
|
- 本目录只新增设计稿与说明,未修改 UIKit、业务逻辑或 Asset Catalog。
|
||||||
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 926 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,484 @@
|
|||||||
|
# 后端消息推送格式
|
||||||
|
|
||||||
|
## 1. 文档说明
|
||||||
|
|
||||||
|
- 本文整理后端经极光推送发送到 Android 和 iOS 的通知消息格式。
|
||||||
|
- 当前内容基于截至 2026-07-23 的 Android、iOS 实际收包日志、两端客户端现有解析逻辑,以及后端开发人员提供的 `PushMsg` 类型定义。
|
||||||
|
- 后端当前定义了 `1`~`13`、`100`、`999` 共 15 个业务消息类型;目前已取得 `type = 1`(付款成功)、`type = 2`(退款成功)、`type = 6`(扫码成功)和 `type = 13`(新获客线索)的收包样本。
|
||||||
|
- 本文描述的是推送协议,不是普通 HTTP 接口响应格式。
|
||||||
|
|
||||||
|
## 2. 统一业务结构
|
||||||
|
|
||||||
|
极光在 Android 和 iOS 上交付的原始结构不同,但当前消息都可以归一化为以下业务结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "通知标题",
|
||||||
|
"content": "通知正文",
|
||||||
|
"msg_id": 1858,
|
||||||
|
"type": 6,
|
||||||
|
"data": {
|
||||||
|
"order_type": 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 公共业务字段
|
||||||
|
|
||||||
|
| 字段 | 当前类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `title` | string | 通知标题。Android 为 `notificationTitle`,iOS 为 `aps.alert.title`。 |
|
||||||
|
| `content` | string | 通知正文。Android 为 `notificationContent`,iOS 为 `aps.alert.body`。 |
|
||||||
|
| `msg_id` | int | 后端业务消息 ID。它与极光消息 ID 不是同一个字段。 |
|
||||||
|
| `type` | int | 业务消息类型,是客户端业务分发的主要依据。 |
|
||||||
|
| `data` | object | 随消息类型变化的业务数据。 |
|
||||||
|
|
||||||
|
> “当前类型”表示现有样本中观察到的 JSON 类型。后端应保持字段类型稳定,避免同一个字段在不同推送中交替使用数字、字符串或 `null`。
|
||||||
|
|
||||||
|
## 3. Android 收包格式
|
||||||
|
|
||||||
|
Android 通过极光 `JPushMessageReceiver.onNotifyMessageArrived` 收到通知。标题、正文和附加字段分别由 SDK 提供,其中 `extras` 是 JSON 字符串:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[onNotifyMessageArrived] msgId=<极光消息ID>, title=<标题>, content=<正文>, extras=<业务JSON字符串>
|
||||||
|
```
|
||||||
|
|
||||||
|
`extras` 的当前结构为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {},
|
||||||
|
"msg_id": 0,
|
||||||
|
"type": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 扫码成功原始日志
|
||||||
|
|
||||||
|
```text
|
||||||
|
[onNotifyMessageArrived] msgId=18103321643292031, title=扫码成功通知, content=扫码成功, extras={"data":{"order_type":14},"msg_id":1852,"type":6}
|
||||||
|
```
|
||||||
|
|
||||||
|
对应的 `extras`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"order_type": 14
|
||||||
|
},
|
||||||
|
"msg_id": 1852,
|
||||||
|
"type": 6
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 付款成功原始日志
|
||||||
|
|
||||||
|
```text
|
||||||
|
[onNotifyMessageArrived] msgId=18103321644290274, title=付款成功通知, content=您的订单 #260722128005 已支付成功,支付金额 ¥0.01, extras={"data":{"order_amount":"0.01","order_number":"260722128005","order_type":14,"pay_time":1784711036,"remark":""},"msg_id":1853,"type":1}
|
||||||
|
```
|
||||||
|
|
||||||
|
对应的 `extras`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"order_amount": "0.01",
|
||||||
|
"order_number": "260722128005",
|
||||||
|
"order_type": 14,
|
||||||
|
"pay_time": 1784711036,
|
||||||
|
"remark": ""
|
||||||
|
},
|
||||||
|
"msg_id": 1853,
|
||||||
|
"type": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 退款成功原始日志
|
||||||
|
|
||||||
|
```text
|
||||||
|
[onNotifyMessageArrived] msgId=18103321800527159, title=退款成功通知, content=您的订单 #260722128002 已成功退款,退款金额 ¥0.01, extras={"data":{},"msg_id":1867,"type":2}
|
||||||
|
```
|
||||||
|
|
||||||
|
对应的 `extras`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {},
|
||||||
|
"msg_id": 1867,
|
||||||
|
"type": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. iOS 收包格式
|
||||||
|
|
||||||
|
iOS 通过 APNs/极光收到 `userInfo` 字典。与 Android 相比:
|
||||||
|
|
||||||
|
- 标题和正文位于 `aps.alert`。
|
||||||
|
- Android `extras` 中的 `msg_id`、`type`、`data` 在 iOS 中位于 payload 顶层。
|
||||||
|
- `_j_*` 为极光传输层内部字段,不应作为业务分发依据。
|
||||||
|
|
||||||
|
通用结构如下:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_j_business": 1,
|
||||||
|
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
|
||||||
|
"_j_msgid": 18103321726881995,
|
||||||
|
"_j_uid": 86399144805,
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"body": "通知正文",
|
||||||
|
"title": "通知标题"
|
||||||
|
},
|
||||||
|
"sound": "default"
|
||||||
|
},
|
||||||
|
"data": {},
|
||||||
|
"msg_id": 0,
|
||||||
|
"type": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 原始日志中该内部字段显示为 `*j\_data*`。本文按极光其他内部字段的命名方式记为 `_j_data`;后续联调时应以设备收到的真实字典 key 为准。该字段目前不参与业务解析。
|
||||||
|
|
||||||
|
### 4.1 扫码成功 payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_j_business": 1,
|
||||||
|
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
|
||||||
|
"_j_msgid": 18103321726881995,
|
||||||
|
"_j_uid": 86399144805,
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"body": "扫码成功",
|
||||||
|
"title": "扫码成功通知"
|
||||||
|
},
|
||||||
|
"sound": "default"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"order_type": 14
|
||||||
|
},
|
||||||
|
"msg_id": 1858,
|
||||||
|
"type": 6
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 付款成功 payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_j_business": 1,
|
||||||
|
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
|
||||||
|
"_j_msgid": 18103321720587147,
|
||||||
|
"_j_uid": 86399144805,
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"body": "您的订单 #260722128006 已支付成功,支付金额 ¥0.01",
|
||||||
|
"title": "付款成功通知"
|
||||||
|
},
|
||||||
|
"sound": "default"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"order_amount": "0.01",
|
||||||
|
"order_number": "260722128006",
|
||||||
|
"order_type": 14,
|
||||||
|
"pay_time": 1784712671,
|
||||||
|
"remark": ""
|
||||||
|
},
|
||||||
|
"msg_id": 1856,
|
||||||
|
"type": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 退款成功 payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_j_business": 1,
|
||||||
|
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
|
||||||
|
"_j_msgid": 18103321792603316,
|
||||||
|
"_j_uid": 86399144805,
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"body": "您的订单 #260722128005 已成功退款,退款金额 ¥0.01",
|
||||||
|
"title": "退款成功通知"
|
||||||
|
},
|
||||||
|
"sound": "default"
|
||||||
|
},
|
||||||
|
"data": {},
|
||||||
|
"msg_id": 1862,
|
||||||
|
"type": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 新获客线索 payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_j_business": 1,
|
||||||
|
"_j_data": "{\"data_msgtype\":1,\"push_type\":8,\"is_vip\":0}",
|
||||||
|
"_j_msgid": 18103323696468714,
|
||||||
|
"_j_uid": 86399144805,
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"body": "获客员哦啦啦提交了一条新线索,请及时查看并跟进",
|
||||||
|
"title": "新获客线索通知"
|
||||||
|
},
|
||||||
|
"sound": "default"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"lead_id": 75,
|
||||||
|
"sale_user_id": 6,
|
||||||
|
"saler_name": "哦啦啦"
|
||||||
|
},
|
||||||
|
"msg_id": 1869,
|
||||||
|
"type": 13
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Android 与 iOS 字段对应关系
|
||||||
|
|
||||||
|
| 语义 | Android | iOS | 是否用于业务 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 极光消息 ID | SDK `message.msgId` | `_j_msgid` | 否,仅用于推送链路日志与排查 |
|
||||||
|
| 通知标题 | SDK `message.notificationTitle` | `aps.alert.title` | 是,用于展示 |
|
||||||
|
| 通知正文 | SDK `message.notificationContent` | `aps.alert.body` | 是,用于展示 |
|
||||||
|
| 后端业务消息 ID | `extras.msg_id` | 顶层 `msg_id` | 是 |
|
||||||
|
| 业务消息类型 | `extras.type` | 顶层 `type` | 是,作为业务分发主键 |
|
||||||
|
| 业务数据 | `extras.data` | 顶层 `data` | 是,结构由 `type` 决定 |
|
||||||
|
| 极光业务标记 | SDK 内部处理 | `_j_business` | 否 |
|
||||||
|
| 极光附加元数据 | SDK 内部处理 | `_j_data` | 否 |
|
||||||
|
| 极光用户 ID | SDK 内部处理 | `_j_uid` | 否 |
|
||||||
|
| APNs 展示配置 | 不适用 | `aps` | 仅 iOS 系统使用 |
|
||||||
|
|
||||||
|
### 5.1 两种消息 ID 不可混用
|
||||||
|
|
||||||
|
- `msgId` / `_j_msgid`:极光生成的传输层消息 ID,当前样本为 17 位数字。
|
||||||
|
- `msg_id`:后端生成的业务消息 ID,当前样本为 `1852`、`1853`、`1856`、`1858`。
|
||||||
|
- 客户端日志、去重或上报时必须明确需要哪一种 ID,不能仅用名称相近而互相替代。
|
||||||
|
- 极光消息 ID 已超过 JavaScript 可安全表示的整数范围;若需要经过 JavaScript、WebView 或其他只能安全处理 53 位整数的链路,应按字符串传递。
|
||||||
|
|
||||||
|
## 6. 消息类型清单
|
||||||
|
|
||||||
|
### 6.1 后端 `PushMsg` 完整类型定义
|
||||||
|
|
||||||
|
定义位置:后端 `PushMsg.php` 第 49 行附近。
|
||||||
|
|
||||||
|
| `type` | 消息类型 | 后端备注 | 当前收包样本 |
|
||||||
|
| ---: | --- | --- | --- |
|
||||||
|
| `1` | 付款成功通知 | 上文实际示例即为此类型 | Android、iOS 均已取得 |
|
||||||
|
| `2` | 退款成功通知 | — | Android、iOS 均已取得 |
|
||||||
|
| `3` | 下单成功通知 | App 无需推送,当前未使用 | 暂无 |
|
||||||
|
| `4` | 签到成功通知 | — | 暂无 |
|
||||||
|
| `5` | 付尾款成功通知 | — | 暂无 |
|
||||||
|
| `6` | 扫码成功通知 | — | Android、iOS 均已取得 |
|
||||||
|
| `7` | 付定金成功通知 | — | 暂无 |
|
||||||
|
| `8` | 订单核销成功通知 | — | 暂无 |
|
||||||
|
| `9` | 剪辑完成通知 | — | 暂无 |
|
||||||
|
| `10` | 修图完成通知 | — | 暂无 |
|
||||||
|
| `11` | 跟拍签到通知 | — | 暂无 |
|
||||||
|
| `12` | 剪辑师新任务通知 | 类型名称映射中漏配 | 暂无 |
|
||||||
|
| `13` | 新获客线索通知 | 获客员提交新线索 | iOS 已取得,Android 暂无 |
|
||||||
|
| `100` | 用户登出通知 | 账号在其他设备登录 | 暂无 |
|
||||||
|
| `999` | 系统消息 | 当前未使用 | 暂无 |
|
||||||
|
|
||||||
|
后端特别说明:
|
||||||
|
|
||||||
|
- `type` 表示业务消息类型,不代表推送渠道。
|
||||||
|
- 推送渠道的取值另有定义:`1` 为极光推送,`2` 为微信小程序,`999` 为不推送。
|
||||||
|
- `type = 12` 虽然已经定义,但尚未加入后端 `MAP_TYPE_NAMES`。后端调用 `getTypeName(12)` 时会返回“未知消息类型”,需要后端补充映射。
|
||||||
|
- “暂无收包样本”只表示当前文档还没有该类型的完整 payload,不能据此认为该类型未启用。收到后应继续补充其标题、正文、`data` 字段和点击行为。
|
||||||
|
|
||||||
|
### 6.2 `type = 1`:付款成功
|
||||||
|
|
||||||
|
| 项目 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标题示例 | `付款成功通知` |
|
||||||
|
| 正文示例 | `您的订单 #260722128006 已支付成功,支付金额 ¥0.01` |
|
||||||
|
| 客户端点击行为 | Android、iOS 均进入收款记录页 |
|
||||||
|
|
||||||
|
`data` 字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 当前样本 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `order_amount` | string | `"0.01"` | 支付金额。使用十进制字符串,避免浮点精度问题。 |
|
||||||
|
| `order_number` | string | `"260722128006"` | 业务订单号。 |
|
||||||
|
| `order_type` | int | `14` | 订单类型;当前值表示“摄影师跟拍线下扫码”。 |
|
||||||
|
| `pay_time` | int | `1784712671` | 支付时间。当前样本为 Unix 秒级时间戳;Android 现有代码同时兼容秒和毫秒。 |
|
||||||
|
| `remark` | string | `""` | 付款备注;没有备注时当前返回空字符串。 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"msg_id": 1856,
|
||||||
|
"data": {
|
||||||
|
"order_amount": "0.01",
|
||||||
|
"order_number": "260722128006",
|
||||||
|
"order_type": 14,
|
||||||
|
"pay_time": 1784712671,
|
||||||
|
"remark": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 `type = 2`:退款成功
|
||||||
|
|
||||||
|
| 项目 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标题示例 | `退款成功通知` |
|
||||||
|
| 正文示例 | `您的订单 #260722128005 已成功退款,退款金额 ¥0.01` |
|
||||||
|
| 客户端点击行为 | Android、iOS 均进入消息中心 |
|
||||||
|
|
||||||
|
`data` 字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 当前样本 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| — | object | `{}` | 当前 Android、iOS 样本均为空对象。 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"msg_id": 1862,
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 当前订单号和退款金额只存在于展示正文中,没有以结构化字段放入 `data`。客户端如果需要可靠地刷新指定订单、展示退款详情或执行金额计算,应由后端在 `data` 中提供字段,不应解析自然语言正文。
|
||||||
|
|
||||||
|
### 6.4 `type = 6`:扫码成功
|
||||||
|
|
||||||
|
| 项目 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标题示例 | `扫码成功通知` |
|
||||||
|
| 正文示例 | `扫码成功` |
|
||||||
|
| 客户端点击行为 | Android、iOS 均进入收款详情页 |
|
||||||
|
|
||||||
|
`data` 字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 当前样本 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `order_type` | int | `14` | 订单类型;`14` 在 Android 现有代码中表示“摄影师跟拍线下扫码”。 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": 6,
|
||||||
|
"msg_id": 1858,
|
||||||
|
"data": {
|
||||||
|
"order_type": 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 `type = 13`:新获客线索
|
||||||
|
|
||||||
|
| 项目 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标题示例 | `新获客线索通知` |
|
||||||
|
| 正文示例 | `获客员哦啦啦提交了一条新线索,请及时查看并跟进` |
|
||||||
|
| 客户端点击行为 | Android、iOS 均进入消息中心 |
|
||||||
|
|
||||||
|
`data` 字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 当前样本 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `lead_id` | int | `75` | 新增获客线索的唯一 ID。 |
|
||||||
|
| `sale_user_id` | int | `6` | 提交线索的获客员 ID。 |
|
||||||
|
| `saler_name` | string | `"哦啦啦"` | 获客员名称,与通知正文中的名称一致。 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": 13,
|
||||||
|
"msg_id": 1869,
|
||||||
|
"data": {
|
||||||
|
"lead_id": 75,
|
||||||
|
"sale_user_id": 6,
|
||||||
|
"saler_name": "哦啦啦"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 当前点击路由只根据 `type` 判断,`type = 13` 属于默认分支,因此进入消息中心;`data` 中的线索字段不会改变跳转页面。
|
||||||
|
|
||||||
|
## 7. 客户端解析约定
|
||||||
|
|
||||||
|
1. Android、iOS 的通知点击路由只根据业务字段 `type` 判断,不读取标题、正文、`data`、`route`、`uri`、`action` 或极光内部字段决定页面。
|
||||||
|
2. `type = 1`:进入收款记录页。
|
||||||
|
3. `type = 6`:进入收款详情页。
|
||||||
|
4. 其他任意 `type`、缺少 `type`、类型无法解析或 payload 损坏:进入消息中心。
|
||||||
|
5. Android 从 `notificationExtras` 或厂商通道包装字段中提取 `type`;iOS 优先读取 payload 顶层 `type`,并兼容常见推送包装层中的 `type`。
|
||||||
|
6. `data` 仍可按消息类型用于页面内容刷新,但不参与通知点击路由。新增字段应保持向后兼容,客户端应忽略当前版本不认识的额外字段。
|
||||||
|
7. 金额继续使用字符串;时间戳建议统一为 Unix 秒级整数,并在协议中固定单位。
|
||||||
|
8. `remark` 等可空业务字段建议固定返回空字符串或明确约定 `null`,不要在多种表示之间切换。
|
||||||
|
|
||||||
|
## 8. 新增消息类型记录模板
|
||||||
|
|
||||||
|
后续收到新类型时,复制以下内容追加到“消息类型清单”:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
### 6.x `type = <类型值>`:<消息名称>
|
||||||
|
|
||||||
|
| 项目 | 当前值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 首次发现日期 | `YYYY-MM-DD` |
|
||||||
|
| 标题示例 | `<title>` |
|
||||||
|
| 正文示例 | `<content>` |
|
||||||
|
| 客户端业务行为 | `<展示、刷新或跳转行为>` |
|
||||||
|
|
||||||
|
`data` 字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必有 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `<field>` | `<type>` | `<是/否/待确认>` | `<说明>` |
|
||||||
|
|
||||||
|
Android `extras` 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {},
|
||||||
|
"msg_id": 0,
|
||||||
|
"type": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
iOS 业务字段示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {},
|
||||||
|
"msg_id": 0,
|
||||||
|
"type": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
记录新类型时至少保留以下信息:
|
||||||
|
|
||||||
|
- Android 和 iOS 各一份原始收包日志。
|
||||||
|
- `type`、`msg_id` 和完整 `data`。
|
||||||
|
- 标题、正文以及消息点击后的期望页面。
|
||||||
|
- 每个字段的 JSON 类型、是否必有、空值表示方式和时间/金额单位。
|
||||||
|
- 同一业务事件在两端的字段是否一致。
|
||||||
|
|
||||||
|
## 9. 待后端确认项
|
||||||
|
|
||||||
|
| 项目 | 当前观察 | 需要确认 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `msg_id` | 与极光消息 ID 不同 | 是否对应消息中心记录 ID,以及是否用于已读/送达状态上报 |
|
||||||
|
| `data` | `type = 1`、`2`、`6`、`13` 的样本中都存在;`type = 2` 为 `{}` | 所有业务推送是否保证存在;无业务字段时是否统一返回 `{}` |
|
||||||
|
| 退款成功的 `data` | 当前为空对象,订单号和退款金额只在正文中 | 是否补充 `order_number`、`refund_amount`、`refund_time`、`order_type` 等结构化字段 |
|
||||||
|
| 其他类型的 `data` | 后端已定义消息名称,但尚未提供 payload | 分别确认 `type = 3`~`5`、`7`~`12`、`100`、`999` 的字段结构、必填项及点击行为 |
|
||||||
|
| `pay_time` | 当前为秒级时间戳 | 后端是否统一保证使用 Unix 秒,且时区语义固定为 UTC 时间戳 |
|
||||||
|
| `remark` | 无备注时为空字符串 | 是否可能返回 `null` 或省略 |
|
||||||
|
| `order_type` | 当前为 `14` | 完整枚举和值含义由哪份接口文档维护 |
|
||||||
|
| 标题与正文 | 当前均存在 | 是否为所有通知消息的必有字段 |
|
||||||
|
| 推送渠道字段 | 后端已给出渠道取值 `1`、`2`、`999` | payload 或发送接口中对应字段的准确名称 |
|
||||||
|
| `type = 12` | `MAP_TYPE_NAMES` 中漏配 | 后端补充映射,避免 `getTypeName(12)` 返回“未知消息类型” |
|
||||||
|
| 新增 `type` | 当前定义维护在 `PushMsg.php` | 后端新增类型前是否可同步类型值、字段结构和客户端行为 |
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 工作周报(2026.08.10—2026.08.14)
|
||||||
|
|
||||||
|
## 本周概况
|
||||||
|
|
||||||
|
本周围绕旅拍相册和 AI 修图功能开展开发,共完成 **23 次提交**,涉及 **37 个文件**,代码变更约新增 **11,495 行**、删除 **1,120 行**。
|
||||||
|
|
||||||
|
## 本周完成
|
||||||
|
|
||||||
|
### 1. 完善旅拍相册图片预览
|
||||||
|
|
||||||
|
- 新增 iOS 端全屏图片预览。
|
||||||
|
- 完善预览工具栏、图片索引胶囊、分段控件及关闭按钮样式。
|
||||||
|
- 支持原图与 AI 修图版本切换,并修复切换时出现黑屏的问题。
|
||||||
|
- 增加相册管理页下拉刷新。
|
||||||
|
- 接入旅拍相册批量删除接口。
|
||||||
|
|
||||||
|
### 2. 打通 AI 修图业务流程
|
||||||
|
|
||||||
|
- 从相册及图片预览页接入 AI 修图入口。
|
||||||
|
- 完成图片选择、模板选择、模板预览和任务提交流程。
|
||||||
|
- 优化修图标签、页面布局及提交反馈。
|
||||||
|
- 新增 AI 修图前后效果对比功能。
|
||||||
|
|
||||||
|
### 3. 新增 AI 修图任务中心
|
||||||
|
|
||||||
|
- 完成任务列表及任务详情页面。
|
||||||
|
- 接入 AI 修图任务相关接口和状态模型。
|
||||||
|
- 支持处理中、成功、失败等任务状态展示。
|
||||||
|
- 打通消息中心、推送通知与任务详情跳转链路。
|
||||||
|
|
||||||
|
### 4. 优化相册数据同步
|
||||||
|
|
||||||
|
- 修复从 OTG 页面返回后旅拍相册未及时刷新的问题。
|
||||||
|
- 优化修图提交后的页面反馈及状态更新。
|
||||||
|
- 完善异常状态和页面切换场景下的数据刷新逻辑。
|
||||||
|
|
||||||
|
### 5. 补充文档与测试
|
||||||
|
|
||||||
|
- 补充 AI 修图需求、接口和交互设计文档。
|
||||||
|
- 完善相册 API、数据模型、ViewModel、消息中心及推送相关单元测试。
|
||||||
|
- 增加图片预览、任务中心和修图流程的回归测试覆盖。
|
||||||
|
|
||||||
|
## 风险与待验证项
|
||||||
|
|
||||||
|
- AI 修图任务中心涉及接口、推送、消息中心和页面跳转,需要结合测试环境继续进行全链路验证。
|
||||||
|
- 需重点回归任务处理中、成功、失败及网络异常等状态。
|
||||||
|
- OTG 返回刷新、批量删除和图片版本切换需要在真机及大相册场景下继续验证。
|
||||||
|
|
||||||
|
## 下周计划
|
||||||
|
|
||||||
|
- 联调 AI 修图任务全生命周期及推送跳转。
|
||||||
|
- 完成旅拍相册与 AI 修图功能的真机回归。
|
||||||
|
- 优化大图加载、任务刷新和弱网场景下的交互体验。
|
||||||
|
- 根据测试反馈修复问题,推进功能验收与发布准备。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 工作周报(2026.08.17—2026.08.21)
|
||||||
|
|
||||||
|
## 本周概况
|
||||||
|
|
||||||
|
本周围绕旅拍相册和 AI 重修流程开展功能完善与体验优化,共完成 **7 次提交**,涉及 **20 个文件**,代码及文档变更约新增 **1,101 行**、删除 **240 行**;同时完成版本号升级,为 **1.3.1** 版本发布做准备。
|
||||||
|
|
||||||
|
## 本周完成
|
||||||
|
|
||||||
|
### 1. 完善 AI 重修业务规则
|
||||||
|
|
||||||
|
- 根据是否已有 AI 结果区分首次修图与重新修图流程。
|
||||||
|
- 已有 AI 结果时,统一支持精修和氛围感模板按需选择,并校验至少选择一种模板。
|
||||||
|
- 优化单图、多图场景下的模板必选规则、生成数量与额度提示。
|
||||||
|
- 对齐 AI 重修请求参数、模板范围及结果覆盖规则,确保未选结果类型保持原有版本不变。
|
||||||
|
|
||||||
|
### 2. 优化相册素材状态与删除保护
|
||||||
|
|
||||||
|
- 将购买状态和 AI 修图状态拆分展示,避免不同业务状态互相覆盖。
|
||||||
|
- 扩展素材选择能力,支持跨相册 Tab 进入选择模式。
|
||||||
|
- 增加已购素材删除保护,批量删除时仅提交未购买素材。
|
||||||
|
- 优化删除确认提示,明确已选、已购及实际可删除数量;全部为已购素材时阻止删除并给出反馈。
|
||||||
|
|
||||||
|
### 3. 改进任务详情与图片预览体验
|
||||||
|
|
||||||
|
- 优化 AI 修图任务详情中的结果类型标签、状态区域和失败信息布局,提升信息辨识度。
|
||||||
|
- 调整任务目标标题、模板和操作按钮布局,改善长文本与不同状态下的适配表现。
|
||||||
|
- 完善图片预览初始版本选择,当前版本不可用时自动回退到有效图片。
|
||||||
|
- 新增高对比度延迟加载指示器,减少快速加载时的闪烁,并优化未知文件大小的展示。
|
||||||
|
|
||||||
|
### 4. 完善相册刷新能力
|
||||||
|
|
||||||
|
- 为旅拍相册入口增加下拉刷新。
|
||||||
|
- 补充刷新状态管理,确保成功、失败及取消场景均能正确结束刷新动画。
|
||||||
|
|
||||||
|
### 5. 补充文档与测试覆盖
|
||||||
|
|
||||||
|
- 新增 AI 重新修图接口优化需求文档,并同步更新 AI 修图需求及接口说明。
|
||||||
|
- 完善 AI 修图模板、重修请求、相册素材模型、删除保护及页面布局相关单元测试。
|
||||||
|
- 增加首次修图、已有结果重修、模板选填和异常状态等关键场景的回归覆盖。
|
||||||
|
|
||||||
|
### 6. 推进版本发布准备
|
||||||
|
|
||||||
|
- 将项目版本号升级至 **1.3.1**。
|
||||||
|
- 整理本轮 AI 重修规则、交互调整及测试范围,为后续联调验收提供依据。
|
||||||
|
|
||||||
|
## 进行中
|
||||||
|
|
||||||
|
- 补充 AI 重修模板占位图的多倍图资源配置。
|
||||||
|
- 继续验证 AI 重修、批量删除及图片预览在真机和真实数据下的完整表现。
|
||||||
|
|
||||||
|
## 风险与待验证项
|
||||||
|
|
||||||
|
- AI 重修规则涉及模板范围、额度计算和结果覆盖,需要结合测试环境验证服务端行为是否与最新规则一致。
|
||||||
|
- 已购与未购素材混合选择时,需要重点回归删除参数、数量提示及刷新后的数据一致性。
|
||||||
|
- 图片预览需继续验证弱网、大图及不同 AI 结果组合下的加载、回退和切换体验。
|
||||||
|
- 本周测试代码已补充,仍需在已连接的 iPhone 真机上完成完整测试回归并记录结果。
|
||||||
|
|
||||||
|
## 下周计划
|
||||||
|
|
||||||
|
- 完成 1.3.1 版本真机测试、问题修复及发布验收。
|
||||||
|
- 联调 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 = 1030101;
|
||||||
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.3.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 = 1030101;
|
||||||
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.3.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 = 1030101;
|
||||||
|
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.3.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,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" : "ai_retouch_template_placeholder.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,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "travel_album_cover_photo_icon.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "travel_album_cover_photo_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "travel_album_cover_photo_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 642 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "travel_album_header_background@1x.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "travel_album_header_background@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "travel_album_header_background@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 225 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "travel_album_sort_icon.png", "idiom" : "universal", "scale" : "1x" },
|
||||||
|
{ "filename" : "travel_album_sort_icon@2x.png", "idiom" : "universal", "scale" : "2x" },
|
||||||
|
{ "filename" : "travel_album_sort_icon@3x.png", "idiom" : "universal", "scale" : "3x" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 588 B |
|
After Width: | Height: | Size: 970 B |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "travel_album_before_after.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 4H20V20H12Z" fill="#000000" fill-opacity="0.32"/>
|
||||||
|
<rect x="4" y="4" width="16" height="16" stroke="#000000" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 4V9L15 12L12 15V20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
@@ -2,129 +2,141 @@ import Foundation
|
|||||||
|
|
||||||
/// 推送点击后可到达的业务页面。
|
/// 推送点击后可到达的业务页面。
|
||||||
enum PushDestination: Sendable, Equatable {
|
enum PushDestination: Sendable, Equatable {
|
||||||
case payment
|
case paymentRecord
|
||||||
case orders
|
case paymentDetails
|
||||||
case verificationOrders
|
|
||||||
case task
|
|
||||||
case queue
|
|
||||||
case messageCenter
|
case messageCenter
|
||||||
|
case aiRetouchTaskList
|
||||||
/// 目标页面是否依赖当前景区上下文。
|
case aiRetouchTaskDetail(batchId: Int)
|
||||||
var requiresScenicContext: Bool {
|
|
||||||
switch self {
|
|
||||||
case .payment, .task, .queue:
|
|
||||||
true
|
|
||||||
case .orders, .verificationOrders, .messageCenter:
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 极光/APNs payload 解析结果,兼容顶层字段及常见 JSON 嵌套格式。
|
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
|
||||||
struct PushPayload: Sendable, Equatable {
|
struct PushPayload: Sendable, Equatable {
|
||||||
private let values: [String: String]
|
private let type: String
|
||||||
|
private let aiRetouchBatchId: Int?
|
||||||
|
|
||||||
/// 从系统通知 userInfo 解析路由字段。
|
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
|
||||||
nonisolated init(userInfo: [AnyHashable: Any]) {
|
nonisolated init(userInfo: [AnyHashable: Any]) {
|
||||||
var flattened: [String: String] = [:]
|
type = Self.extractType(from: userInfo)
|
||||||
Self.mergeDictionary(userInfo, into: &flattened)
|
aiRetouchBatchId = Self.extractBatchId(from: userInfo)
|
||||||
values = flattened
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从可发送的字符串字典创建 payload,供跨 Actor 回调及测试使用。
|
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
|
||||||
nonisolated init(values: [String: String]) {
|
nonisolated init(values: [String: String]) {
|
||||||
self.values = values
|
type = Self.normalizedType(values["type"])
|
||||||
|
aiRetouchBatchId = Int(values["ai_retouch_batch_id"] ?? "").flatMap { $0 > 0 ? $0 : nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 可发送的规范化字段快照。
|
/// 可发送的规范化字段快照。
|
||||||
nonisolated var normalizedValues: [String: String] {
|
nonisolated var normalizedValues: [String: String] {
|
||||||
values
|
var values = type.isEmpty ? [:] : ["type": type]
|
||||||
|
if let aiRetouchBatchId { values["ai_retouch_batch_id"] = String(aiRetouchBatchId) }
|
||||||
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根据后端约定和历史兼容字段解析目标页面。
|
/// 仅根据后端业务消息类型解析目标页面。
|
||||||
nonisolated var destination: PushDestination {
|
nonisolated var destination: PushDestination {
|
||||||
let type = value(for: "type")
|
switch type {
|
||||||
let route = value(for: "route")
|
case "1":
|
||||||
let uri = value(for: "uri")
|
return .paymentRecord
|
||||||
let action = value(for: "action")
|
case "6":
|
||||||
let merged = [route, uri, action].joined(separator: " ").lowercased()
|
return .paymentDetails
|
||||||
|
case "14":
|
||||||
if type == "1" || type == "6"
|
if let aiRetouchBatchId, aiRetouchBatchId > 0 {
|
||||||
|| merged.contains("payment") || merged.contains("payment_qr")
|
return .aiRetouchTaskDetail(batchId: aiRetouchBatchId)
|
||||||
|| merged.contains("收款") {
|
}
|
||||||
return .payment
|
return .aiRetouchTaskList
|
||||||
}
|
default:
|
||||||
if merged.contains("queue") || merged.contains("scenic-queue")
|
return .messageCenter
|
||||||
|| merged.contains("排队") || merged.contains("叫号") {
|
|
||||||
return .queue
|
|
||||||
}
|
|
||||||
if merged.contains("verification") || merged.contains("writeoff")
|
|
||||||
|| merged.contains("write_off") || merged.contains("核销") {
|
|
||||||
return .verificationOrders
|
|
||||||
}
|
|
||||||
if merged.contains("order") || merged.contains("订单") {
|
|
||||||
return .orders
|
|
||||||
}
|
|
||||||
if merged.contains("task") || merged.contains("任务") {
|
|
||||||
return .task
|
|
||||||
}
|
|
||||||
return .messageCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated func value(for key: String) -> String {
|
|
||||||
values[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated static func mergeDictionary(
|
|
||||||
_ dictionary: [AnyHashable: Any],
|
|
||||||
into result: inout [String: String]
|
|
||||||
) {
|
|
||||||
for (rawKey, value) in dictionary {
|
|
||||||
guard let key = rawKey as? String else { continue }
|
|
||||||
merge(value, key: key, into: &result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func merge(
|
private nonisolated static func extractType(
|
||||||
_ value: Any,
|
from dictionary: [AnyHashable: Any],
|
||||||
key: String,
|
depth: Int = 0
|
||||||
into result: inout [String: String]
|
) -> String {
|
||||||
) {
|
guard depth <= 4 else { return "" }
|
||||||
if let dictionary = value as? [String: Any] {
|
let directType = normalizedType(dictionary["type"])
|
||||||
result[key] = stringValue(value)
|
guard directType.isEmpty else { return directType }
|
||||||
mergeDictionary(dictionary, into: &result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let dictionary = value as? [AnyHashable: Any] {
|
|
||||||
result[key] = stringValue(value)
|
|
||||||
mergeDictionary(dictionary, into: &result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = stringValue(value)
|
for key in nestedTypeContainerKeys {
|
||||||
result[key] = text
|
guard let value = dictionary[key] else { continue }
|
||||||
guard Self.nestedJSONKeys.contains(key),
|
if let nested = value as? [AnyHashable: Any] {
|
||||||
let data = text.data(using: .utf8),
|
let nestedType = extractType(from: nested, depth: depth + 1)
|
||||||
let object = try? JSONSerialization.jsonObject(with: data)
|
if !nestedType.isEmpty { return nestedType }
|
||||||
else { return }
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if let dictionary = object as? [String: Any] {
|
guard let text = value as? String,
|
||||||
mergeDictionary(dictionary, into: &result)
|
let data = text.data(using: .utf8),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data),
|
||||||
|
let nested = object as? [String: Any]
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
let bridged = Dictionary(uniqueKeysWithValues: nested.map {
|
||||||
|
(AnyHashable($0.key), $0.value)
|
||||||
|
})
|
||||||
|
let nestedType = extractType(from: bridged, depth: depth + 1)
|
||||||
|
if !nestedType.isEmpty { return nestedType }
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func stringValue(_ value: Any) -> String {
|
private nonisolated static func normalizedType(_ value: Any?) -> String {
|
||||||
switch value {
|
switch value {
|
||||||
case let string as String:
|
case let string as String:
|
||||||
string
|
return string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
case let number as NSNumber:
|
case let number as NSNumber:
|
||||||
number.stringValue
|
return number.stringValue
|
||||||
default:
|
default:
|
||||||
String(describing: value)
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static let nestedJSONKeys: Set<String> = [
|
private nonisolated static func extractBatchId(
|
||||||
"extras", "extra", "data", "JMessageExtra", "n_extras",
|
from dictionary: [AnyHashable: Any],
|
||||||
|
depth: Int = 0
|
||||||
|
) -> Int? {
|
||||||
|
guard depth <= 5 else { return nil }
|
||||||
|
if let value = normalizedPositiveInt(dictionary["ai_retouch_batch_id"]) { return value }
|
||||||
|
|
||||||
|
if let data = dictionary["data"] {
|
||||||
|
if let nested = data as? [AnyHashable: Any],
|
||||||
|
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
|
||||||
|
if let text = data as? String,
|
||||||
|
let nested = jsonDictionary(text),
|
||||||
|
let value = extractBatchId(from: nested, depth: depth + 1) { return value }
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in nestedTypeContainerKeys {
|
||||||
|
guard let value = dictionary[key] else { continue }
|
||||||
|
if let nested = value as? [AnyHashable: Any],
|
||||||
|
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
|
||||||
|
if let text = value as? String,
|
||||||
|
let nested = jsonDictionary(text),
|
||||||
|
let batchId = extractBatchId(from: nested, depth: depth + 1) { return batchId }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func normalizedPositiveInt(_ value: Any?) -> Int? {
|
||||||
|
let parsed: Int?
|
||||||
|
switch value {
|
||||||
|
case let number as NSNumber: parsed = number.intValue
|
||||||
|
case let string as String: parsed = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
default: parsed = nil
|
||||||
|
}
|
||||||
|
return parsed.flatMap { $0 > 0 ? $0 : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func jsonDictionary(_ text: String) -> [AnyHashable: Any]? {
|
||||||
|
guard let data = text.data(using: .utf8),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data),
|
||||||
|
let dictionary = object as? [String: Any]
|
||||||
|
else { return nil }
|
||||||
|
return Dictionary(uniqueKeysWithValues: dictionary.map { (AnyHashable($0.key), $0.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static let nestedTypeContainerKeys = [
|
||||||
|
"extras", "extra", "JMessageExtra", "n_extras",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ final class PushNotificationManager: NSObject {
|
|||||||
/// 处理 Scene 冷启动携带的系统通知响应,并向极光上报点击。
|
/// 处理 Scene 冷启动携带的系统通知响应,并向极光上报点击。
|
||||||
func handleNotificationResponse(_ response: UNNotificationResponse) {
|
func handleNotificationResponse(_ response: UNNotificationResponse) {
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
Self.logReceivedPush(userInfo, source: "tap(cold-start)")
|
||||||
JPUSHService.handleRemoteNotification(userInfo)
|
JPUSHService.handleRemoteNotification(userInfo)
|
||||||
handleNotificationTap(
|
handleNotificationTap(
|
||||||
payload: PushPayload(userInfo: userInfo),
|
payload: PushPayload(userInfo: userInfo),
|
||||||
@@ -238,10 +239,35 @@ final class PushNotificationManager: NSObject {
|
|||||||
|
|
||||||
/// 处理后台静默通知并向极光上报到达,不触发页面跳转。
|
/// 处理后台静默通知并向极光上报到达,不触发页面跳转。
|
||||||
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
|
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
|
||||||
|
Self.logReceivedPush(userInfo, source: "background")
|
||||||
JPUSHService.handleRemoteNotification(userInfo)
|
JPUSHService.handleRemoteNotification(userInfo)
|
||||||
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
|
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将收到的推送 payload 打印到控制台,便于联调排查。
|
||||||
|
private nonisolated static func logReceivedPush(
|
||||||
|
_ userInfo: [AnyHashable: Any],
|
||||||
|
source: String
|
||||||
|
) {
|
||||||
|
#if DEBUG
|
||||||
|
let payloadText: String
|
||||||
|
let normalized = Dictionary(uniqueKeysWithValues: userInfo.compactMap { key, value in
|
||||||
|
(key as? String).map { ($0, value) }
|
||||||
|
})
|
||||||
|
if JSONSerialization.isValidJSONObject(normalized),
|
||||||
|
let data = try? JSONSerialization.data(
|
||||||
|
withJSONObject: normalized,
|
||||||
|
options: [.prettyPrinted, .sortedKeys]
|
||||||
|
),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
payloadText = text
|
||||||
|
} else {
|
||||||
|
payloadText = String(describing: userInfo)
|
||||||
|
}
|
||||||
|
print("[Push] received (\(source)):\n\(payloadText)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func observeJPushNetworkLogin() {
|
private func observeJPushNetworkLogin() {
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
@@ -352,7 +378,9 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
|
|||||||
willPresent notification: UNNotification,
|
willPresent notification: UNNotification,
|
||||||
withCompletionHandler completionHandler: @escaping (Int) -> Void
|
withCompletionHandler completionHandler: @escaping (Int) -> Void
|
||||||
) {
|
) {
|
||||||
JPUSHService.handleRemoteNotification(notification.request.content.userInfo)
|
let userInfo = notification.request.content.userInfo
|
||||||
|
Self.logReceivedPush(userInfo, source: "foreground")
|
||||||
|
JPUSHService.handleRemoteNotification(userInfo)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
|
NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil)
|
||||||
}
|
}
|
||||||
@@ -367,6 +395,7 @@ extension PushNotificationManager: JPUSHRegisterDelegate {
|
|||||||
withCompletionHandler completionHandler: @escaping () -> Void
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
Self.logReceivedPush(userInfo, source: "tap")
|
||||||
JPUSHService.handleRemoteNotification(userInfo)
|
JPUSHService.handleRemoteNotification(userInfo)
|
||||||
let payload = PushPayload(userInfo: userInfo)
|
let payload = PushPayload(userInfo: userInfo)
|
||||||
let requestIdentifier = response.notification.request.identifier
|
let requestIdentifier = response.notification.request.identifier
|
||||||
|
|||||||
@@ -84,16 +84,8 @@ final class PushRouteCoordinator: PushRouting {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let destination: PushDestination
|
|
||||||
if route.destination.requiresScenicContext,
|
|
||||||
appStore.session.currentScenicId <= 0 {
|
|
||||||
destination = .messageCenter
|
|
||||||
} else {
|
|
||||||
destination = route.destination
|
|
||||||
}
|
|
||||||
|
|
||||||
mainTabController.dismiss(animated: false)
|
mainTabController.dismiss(animated: false)
|
||||||
mainTabController.openPushDestination(destination)
|
mainTabController.openPushDestination(route.destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markHandled(_ identifier: String) {
|
private func markHandled(_ identifier: String) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -196,7 +196,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 +211,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 +237,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 +255,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 +275,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 +292,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 +317,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 +336,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,37 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
|
|||||||
self = .object(try container.decode([String: MessageJSONValue].self))
|
self = .object(try container.decode([String: MessageJSONValue].self))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将消息字段转换为合法的正整数标识;兼容后端返回数字或数字字符串。
|
||||||
|
var positiveInt: Int? {
|
||||||
|
let value: Int?
|
||||||
|
switch self {
|
||||||
|
case let .number(number):
|
||||||
|
guard number.isFinite, number.rounded() == number else { return nil }
|
||||||
|
value = Int(exactly: number)
|
||||||
|
case let .string(string):
|
||||||
|
value = Int(string.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
default:
|
||||||
|
value = nil
|
||||||
|
}
|
||||||
|
guard let value, value > 0 else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回对象类型的原始键值。
|
||||||
|
var objectValue: [String: MessageJSONValue]? {
|
||||||
|
switch self {
|
||||||
|
case let .object(value):
|
||||||
|
return value
|
||||||
|
case let .string(value):
|
||||||
|
guard let data = value.data(using: .utf8),
|
||||||
|
let decoded = try? JSONDecoder().decode(MessageJSONValue.self, from: data)
|
||||||
|
else { return nil }
|
||||||
|
return decoded.objectValue
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
|
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
|
||||||
@@ -203,6 +234,20 @@ struct MessageItem: Decodable, Hashable, Sendable {
|
|||||||
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
|
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是否为 AI 修图任务通知;人工修图的 `type = 10` 不属于该类型。
|
||||||
|
var isAIRetouchTaskNotification: Bool {
|
||||||
|
type == 14
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图批次 ID,兼容 `extra_data` 直接字段及 `extra_data.data` 包装结构。
|
||||||
|
var aiRetouchBatchId: Int? {
|
||||||
|
guard isAIRetouchTaskNotification, let extraData else { return nil }
|
||||||
|
if let batchId = extraData["ai_retouch_batch_id"]?.positiveInt {
|
||||||
|
return batchId
|
||||||
|
}
|
||||||
|
return extraData["data"]?.objectValue?["ai_retouch_batch_id"]?.positiveInt
|
||||||
|
}
|
||||||
|
|
||||||
/// 返回已读状态的消息副本。
|
/// 返回已读状态的消息副本。
|
||||||
func markedRead() -> MessageItem {
|
func markedRead() -> MessageItem {
|
||||||
MessageItem(
|
MessageItem(
|
||||||
|
|||||||
@@ -163,6 +163,16 @@ final class MessageDetailViewModel {
|
|||||||
self.message = message
|
self.message = message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务通知在详情页展示任务入口。
|
||||||
|
var showsAIRetouchTaskAction: Bool {
|
||||||
|
message.isAIRetouchTaskNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前消息携带的 AI 修图批次 ID;缺失时由页面降级进入任务列表。
|
||||||
|
var aiRetouchBatchId: Int? {
|
||||||
|
message.aiRetouchBatchId
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除当前消息。
|
/// 删除当前消息。
|
||||||
func delete(api: any MessageCenterServing) async {
|
func delete(api: any MessageCenterServing) async {
|
||||||
guard message.id > 0 else {
|
guard message.id > 0 else {
|
||||||
|
|||||||
@@ -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,816 @@
|
|||||||
|
# AI 修图任务中心需求与接口设计
|
||||||
|
|
||||||
|
> 文档状态:待产品、后端、Android、iOS 联审
|
||||||
|
> 更新日期:2026-08-14
|
||||||
|
> 适用范围:随心瞰商家版 AI 修图任务,不包含人工修图任务
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
AI 修图属于异步长耗时任务。当前用户提交后只能等待或主动返回相册刷新,无法明确知道任务是否仍在排队、预计何时完成、哪些照片成功或失败。
|
||||||
|
|
||||||
|
本需求增加:
|
||||||
|
|
||||||
|
1. 当前账号全部相册的 AI 修图任务列表。
|
||||||
|
2. 单次 AI 修图任务详情。
|
||||||
|
3. AI 修图终态消息推送。
|
||||||
|
4. 从推送和任务列表进入对应任务的完整导航链路;提交成功后仅 Toast 提示并返回相册管理。
|
||||||
|
|
||||||
|
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
|
||||||
|
|
||||||
|
## 2. 核心产品决策
|
||||||
|
|
||||||
|
### 2.1 推送跳转结论
|
||||||
|
|
||||||
|
AI 修图推送优先跳转到对应任务详情页。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 用户点击完成通知时,核心意图是确认这一次任务的结果,而不是重新查找任务。
|
||||||
|
- 详情页可以直接表达成功、部分成功和失败,减少一次列表定位操作。
|
||||||
|
- `ai_retouch_batch_id` 是稳定任务标识,可以支撑前台、后台和冷启动直达。
|
||||||
|
|
||||||
|
以下情况降级进入任务列表:
|
||||||
|
|
||||||
|
- 推送缺少 `ai_retouch_batch_id`。
|
||||||
|
- ID 类型异常、为 0 或负数。
|
||||||
|
- 详情接口返回任务不存在、已失效或当前账号无权访问。
|
||||||
|
- 未来客户端收到无法识别的 AI 修图终态数据。
|
||||||
|
|
||||||
|
### 2.2 推送类型
|
||||||
|
|
||||||
|
| `type` | 业务含义 | 本需求处理 |
|
||||||
|
|---:|---|---|
|
||||||
|
| `10` | 人工修图完成通知 | 保持原业务语义和原点击行为,不作复用 |
|
||||||
|
| `14` | AI 修图任务通知 | 新增,按 `ai_retouch_batch_id` 进入 AI 修图任务详情 |
|
||||||
|
|
||||||
|
后端需在 `PushMsg` 类型定义及类型名称映射中增加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
14 => AI修图任务通知
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 任务唯一标识
|
||||||
|
|
||||||
|
列表、详情、推送和提交响应统一使用已有的 `ai_retouch_batch_id`,类型固定为正整数。不得再引入另一套 `job_id`,避免与素材接口和重新修图接口中的批次标识无法对应。
|
||||||
|
|
||||||
|
### 2.4 列表范围
|
||||||
|
|
||||||
|
任务列表展示当前登录账号有权限查看的全部相册任务,不要求用户先进入某个相册。任务卡必须显示所属相册信息。
|
||||||
|
|
||||||
|
## 3. 用户流程与入口
|
||||||
|
|
||||||
|
### 3.1 主流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
相册管理选择照片
|
||||||
|
→ 提交 AI 修图
|
||||||
|
→ 后端返回 ai_retouch_batch_id
|
||||||
|
→ 客户端 Toast 提示“AI修图任务已提交,完成后将通过消息通知”
|
||||||
|
→ 关闭模板页及可能存在的照片预览页,回到相册管理并刷新列表
|
||||||
|
→ 用户可离开页面
|
||||||
|
→ 任务进入终态后收到 type = 14 推送
|
||||||
|
→ 点击推送进入任务详情
|
||||||
|
→ 查看成功结果或进入相册处理失败项
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 页面入口
|
||||||
|
|
||||||
|
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
|
||||||
|
- AI 修图提交成功仅展示自动消失的 Toast,不弹出查看任务确认框,也不提供立即跳转操作。
|
||||||
|
- 提交成功后回到相册管理页,并刷新相册摘要、数量和当前素材列表。
|
||||||
|
- 任务列表点击卡片进入对应任务详情。
|
||||||
|
- `type = 14` 推送携带有效任务 ID 时直达详情,否则进入任务列表。
|
||||||
|
|
||||||
|
## 4. 任务状态定义
|
||||||
|
|
||||||
|
后端状态值必须稳定,客户端根据枚举映射中文,不依赖后端返回的中文状态名。
|
||||||
|
|
||||||
|
| 状态 | 是否终态 | 列表分组 | 中文展示 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `queued` | 否 | 进行中 | 排队中 | 已受理,尚无子任务开始 |
|
||||||
|
| `processing` | 否 | 进行中 | 修图中 | 至少一个子任务开始,尚未全部结束 |
|
||||||
|
| `succeeded` | 是 | 已完成 | 已完成 | 所有子任务成功 |
|
||||||
|
| `partially_succeeded` | 是 | 已完成 | 部分完成 | 子任务全部结束,既有成功也有失败或取消 |
|
||||||
|
| `failed` | 是 | 失败 | 处理失败 | 子任务全部结束,没有成功结果 |
|
||||||
|
| `canceled` | 是 | 失败 | 已取消 | 任务因素材删除或后台操作被取消 |
|
||||||
|
|
||||||
|
进度必须满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
total = queued + processing + succeeded + failed + canceled
|
||||||
|
completed = succeeded + failed + canceled
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端进度百分比只能通过 `completed / total` 计算;`total <= 0` 时隐藏进度百分比,不显示虚构进度。
|
||||||
|
|
||||||
|
## 5. 任务列表页
|
||||||
|
|
||||||
|
### 5.1 设计稿
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 5.2 页面结构
|
||||||
|
|
||||||
|
1. 导航栏
|
||||||
|
- 返回按钮。
|
||||||
|
- 标题“AI修图任务”。
|
||||||
|
2. 状态筛选
|
||||||
|
- 全部。
|
||||||
|
- 进行中。
|
||||||
|
- 已完成。
|
||||||
|
- 失败。
|
||||||
|
3. 任务卡列表
|
||||||
|
- 所属相册名称。
|
||||||
|
- 用户脱敏手机号。
|
||||||
|
- 提交时间。
|
||||||
|
- 任务编号,例如“任务 #9521”。
|
||||||
|
- 1 至 3 张原图缩略图;多余图片通过数量表达,不继续横向堆叠。
|
||||||
|
- 输出摘要,例如“精修 4 张 · 氛围感 4 张 · 封面 1 张”。
|
||||||
|
- 状态图标与文字标签。
|
||||||
|
- 进度或终态结果摘要。
|
||||||
|
- 详情指示。
|
||||||
|
|
||||||
|
### 5.3 状态展示
|
||||||
|
|
||||||
|
| 状态 | 卡片主信息 | 辅助信息 |
|
||||||
|
|---|---|---|
|
||||||
|
| `queued` | 排队中 | 有 ETA 时显示预计完成时间,否则显示“完成后将通过消息通知” |
|
||||||
|
| `processing` | 已完成 N / M | 显示进度条与预计完成时间 |
|
||||||
|
| `succeeded` | N 张结果已生成 | 显示完成时间 |
|
||||||
|
| `partially_succeeded` | N 张成功 · M 张失败 | 使用橙色警示,不按整单失败展示 |
|
||||||
|
| `failed` | 处理失败 | 引导点击查看详情 |
|
||||||
|
| `canceled` | 已取消 | 显示取消时间;不展示供应商或内部日志 |
|
||||||
|
|
||||||
|
### 5.4 失败信息展示
|
||||||
|
|
||||||
|
- 列表只承担任务级概览,不逐张展开失败原因。
|
||||||
|
- `partially_succeeded` 使用“N 张成功 · M 张失败”作为唯一结果摘要;`failed` 使用“失败”,不再增加独立的失败摘要行。
|
||||||
|
- 列表卡整体可点击并保留“查看详情”指示,具体失败原因统一进入详情查看,避免与结果数量重复。
|
||||||
|
- 后端返回的 `failure_summary` 继续解析并保留,供通知、无逐项错误数据等降级场景使用;列表首版不直接展示。
|
||||||
|
- 详情里的逐照片 `error.message` 仍是用户查看失败原因的主要信息来源,不展示原始错误码。
|
||||||
|
|
||||||
|
### 5.5 刷新与分页
|
||||||
|
|
||||||
|
- 首次进入、下拉刷新和 App 回到前台时拉取第一页。
|
||||||
|
- 页面可见且存在 `queued` 或 `processing` 任务时,每 15 秒刷新第一页。
|
||||||
|
- 页面离开、App 进入后台或页面内所有任务终态后停止定时刷新。
|
||||||
|
- 加载更多使用不透明游标;客户端不得解析或拼接游标。
|
||||||
|
- 服务端排序固定为 `created_at DESC, ai_retouch_batch_id DESC`,避免同一时间创建的任务分页不稳定。
|
||||||
|
- 刷新第一页时按 `ai_retouch_batch_id` 合并,不能产生重复卡片。
|
||||||
|
|
||||||
|
### 5.6 空态和异常
|
||||||
|
|
||||||
|
| 场景 | 展示 |
|
||||||
|
|---|---|
|
||||||
|
| 账号从未提交任务 | “暂无AI修图任务”与“提交修图后可在这里查看进度” |
|
||||||
|
| 当前筛选无数据 | “暂无该状态的任务” |
|
||||||
|
| 首屏加载失败 | 错误说明和“重新加载” |
|
||||||
|
| 加载更多失败 | 保留现有列表,底部提供重试 |
|
||||||
|
|
||||||
|
## 6. 任务详情页
|
||||||
|
|
||||||
|
### 6.1 设计稿
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 6.2 页面结构
|
||||||
|
|
||||||
|
1. 导航栏
|
||||||
|
- 返回按钮。
|
||||||
|
- 标题“任务详情”。
|
||||||
|
- 手动刷新按钮。
|
||||||
|
2. 状态摘要卡
|
||||||
|
- 任务状态图标和文字。
|
||||||
|
- 完成数量与进度条。
|
||||||
|
- 预计完成时间或实际完成时间。
|
||||||
|
- 非终态展示“完成后将通过消息通知你”。
|
||||||
|
3. 相册与任务信息
|
||||||
|
- 相册封面、相册名称、脱敏手机号。
|
||||||
|
- 任务编号、提交时间、开始时间、完成时间或处理耗时。
|
||||||
|
4. 任务内容
|
||||||
|
- 精修、氛围感、封面的目标数量。
|
||||||
|
- 额度预占、实际消耗和释放数量。
|
||||||
|
5. 处理明细
|
||||||
|
- 按原图组织精修和氛围感子任务。
|
||||||
|
- 封面作为独立明细。
|
||||||
|
- 每项展示照片缩略图、文件名、模板名称和状态。
|
||||||
|
- 某个输出失败时,在该照片、该输出项内部紧邻状态展示后端失败原因。
|
||||||
|
6. 页面操作
|
||||||
|
- 成功结果:“查看结果”,进入照片预览对应 Tab。
|
||||||
|
- 失败结果:仅展示后端返回的用户可理解失败原因,不在失败原因后追加操作按钮。
|
||||||
|
- 页面底部始终可提供“查看相册”。
|
||||||
|
|
||||||
|
### 6.3 失败原因展示
|
||||||
|
|
||||||
|
失败原因必须和失败照片、失败输出类型绑定展示,不使用全局弹窗、Toast 或脱离上下文的页面顶部提示代替。
|
||||||
|
|
||||||
|
| 场景 | 展示规则 |
|
||||||
|
|---|---|
|
||||||
|
| 单个精修/氛围感失败 | 在对应输出项状态“生成失败”下方显示“失败原因:{error.message}” |
|
||||||
|
| 同一照片两种输出均失败 | 精修、氛围感分别显示各自原因,不合并为一个模糊原因 |
|
||||||
|
| 封面失败 | 在封面独立明细下方显示原因 |
|
||||||
|
| 任务仍在处理中但已有失败项 | 立即展示已确定的照片失败原因,不等整批任务终态 |
|
||||||
|
| 后端原因为空 | 展示客户端兜底“处理失败,请稍后重试” |
|
||||||
|
| 原因超过两行 | 默认显示两行并提供“查看完整原因”;辅助功能朗读完整文本 |
|
||||||
|
|
||||||
|
视觉规则:
|
||||||
|
|
||||||
|
- 使用红色错误图标、红色“生成失败”和淡红色原因容器;颜色不是唯一状态提示。
|
||||||
|
- `error.message` 使用正文级字号和足够对比度,不使用脚注小字弱化重要信息。
|
||||||
|
- 不展示 `error.code`、供应商名称、堆栈、请求 ID 或服务器路径。
|
||||||
|
- 无论 `retryable` 取值如何,失败原因区域均只展示原因;用户仍可通过详情页底部“查看相册”进入相册管理页。
|
||||||
|
- 用户返回页面或手动刷新后,失败原因随接口最新值更新。
|
||||||
|
|
||||||
|
### 6.4 刷新规则
|
||||||
|
|
||||||
|
- 详情首次出现时立即请求最新数据。
|
||||||
|
- 非终态且页面可见时每 8 秒刷新。
|
||||||
|
- 进入终态、页面离开或 App 进入后台时停止刷新。
|
||||||
|
- 手动刷新与定时刷新不能并发发起重复请求。
|
||||||
|
- 推送不作为唯一状态来源;用户关闭通知权限后仍可通过页面刷新获得终态。
|
||||||
|
|
||||||
|
### 6.5 查看结果规则
|
||||||
|
|
||||||
|
- `output_type = refined`:打开来源原图的照片预览,默认选中“精修后”。
|
||||||
|
- `output_type = atmosphere`:打开来源原图的照片预览,默认选中“氛围感”。
|
||||||
|
- `output_type = cover`:打开所属相册,并定位到生成的封面素材;无法定位时进入相册第一页。
|
||||||
|
- 结果资源已删除或不可用时,隐藏“查看结果”并展示“结果已失效”。
|
||||||
|
|
||||||
|
## 7. 视觉与可访问性规范
|
||||||
|
|
||||||
|
### 7.1 视觉 Token
|
||||||
|
|
||||||
|
| 用途 | 建议值 |
|
||||||
|
|---|---|
|
||||||
|
| 页面背景 | `#F5F7FB` |
|
||||||
|
| 卡片背景 | `#FFFFFF` |
|
||||||
|
| 品牌主色 | `#1677FF` |
|
||||||
|
| 蓝色高光 | `#3A91FF` |
|
||||||
|
| 主文字 | `#111827` |
|
||||||
|
| 次文字 | `#64748B` |
|
||||||
|
| 成功 | `#22A06B` |
|
||||||
|
| 警示/部分完成 | `#F59E0B` |
|
||||||
|
| 失败 | `#EF4444` |
|
||||||
|
| 卡片圆角 | 12–16 pt |
|
||||||
|
|
||||||
|
### 7.2 交互要求
|
||||||
|
|
||||||
|
- 状态必须同时使用图标、文字和颜色,不得只用颜色区分。
|
||||||
|
- 正文文字与背景对比度至少 4.5:1。
|
||||||
|
- 卡片、筛选项和操作按钮的最小触控区域为 44 × 44 pt。
|
||||||
|
- 动态字体至少覆盖系统默认至辅助功能常用档位;文字放大时允许卡片增高。
|
||||||
|
- 进度动画尊重“减弱动态效果”;关闭动画后保留静态进度与文字。
|
||||||
|
- 网络图片使用缩略图地址,并提供占位图和失败占位状态。
|
||||||
|
|
||||||
|
## 8. 后端接口总览
|
||||||
|
|
||||||
|
基础路径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/yf-handset-app/photog/travel-album
|
||||||
|
```
|
||||||
|
|
||||||
|
| 方法 | 路径 | 类型 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `POST` | `/ai-retouch` | 扩展现有响应 | 首次/批量提交后返回任务标识 |
|
||||||
|
| `POST` | `/ai-reretouch` | 扩展现有响应 | 重新修图提交后返回任务标识 |
|
||||||
|
| `GET` | `/ai-retouch-job-list` | 新增 | 获取当前账号全部 AI 修图任务 |
|
||||||
|
| `GET` | `/ai-retouch-job-info` | 实现并统一 | 获取指定任务详情 |
|
||||||
|
|
||||||
|
时间字段统一使用 ISO 8601 UTC,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-08-14T06:26:12.123Z
|
||||||
|
```
|
||||||
|
|
||||||
|
所有 ID 的 JSON 类型必须稳定;本需求中的 `ai_retouch_batch_id`、`user_equity_travel_id` 和素材 ID 均使用整数。
|
||||||
|
|
||||||
|
## 9. 扩展 AI 修图提交响应
|
||||||
|
|
||||||
|
现有 `/ai-retouch` 和 `/ai-reretouch` 请求体保持不变,成功后统一返回可供跳转和立即展示的任务摘要。
|
||||||
|
|
||||||
|
HTTP 状态码:`202 Accepted`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "AI修图任务已提交",
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"status": "queued",
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 9,
|
||||||
|
"processing": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"canceled": 0
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
必有字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ai_retouch_batch_id` | int | 大于 0 的任务唯一标识 |
|
||||||
|
| `user_equity_travel_id` | int | 所属相册 ID |
|
||||||
|
| `status` | string | 提交成功时通常为 `queued` |
|
||||||
|
| `progress` | object | 提交时的真实子任务数量 |
|
||||||
|
| `created_at` | string | 服务端受理时间 |
|
||||||
|
|
||||||
|
客户端超时后使用原幂等键重试时,后端必须返回同一个 `ai_retouch_batch_id`,不得创建重复任务。
|
||||||
|
|
||||||
|
## 10. 获取 AI 修图任务列表
|
||||||
|
|
||||||
|
### 10.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-list?status_group=in_progress&limit=20&cursor=<opaque_cursor>
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 类型 | 默认值 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `status_group` | 否 | string | `all` | `all`、`in_progress`、`completed`、`failed` |
|
||||||
|
| `limit` | 否 | int | `20` | 最小 1,最大 50 |
|
||||||
|
| `cursor` | 否 | string | — | 服务端返回的不透明游标,第一页不传 |
|
||||||
|
|
||||||
|
服务端分组映射:
|
||||||
|
|
||||||
|
| `status_group` | 包含状态 |
|
||||||
|
|---|---|
|
||||||
|
| `all` | 全部六种状态 |
|
||||||
|
| `in_progress` | `queued`、`processing` |
|
||||||
|
| `completed` | `succeeded`、`partially_succeeded` |
|
||||||
|
| `failed` | `failed`、`canceled` |
|
||||||
|
|
||||||
|
### 10.2 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"scope": "batch",
|
||||||
|
"status": "processing",
|
||||||
|
"album": {
|
||||||
|
"id": 88,
|
||||||
|
"name": "旅拍相册",
|
||||||
|
"user_phone": "13812348000",
|
||||||
|
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
|
||||||
|
},
|
||||||
|
"source_count": 4,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"preview_images": [
|
||||||
|
{
|
||||||
|
"material_id": 2031,
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"material_id": 2032,
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"material_id": 2033,
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 3,
|
||||||
|
"processing": 1,
|
||||||
|
"succeeded": 5,
|
||||||
|
"failed": 0,
|
||||||
|
"canceled": 0
|
||||||
|
},
|
||||||
|
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
|
||||||
|
"failure_summary": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:26:18.000Z",
|
||||||
|
"finished_at": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0xNFQwNjoyNjoxMi4xMjNaIiwiaWQiOjk1MjF9",
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 列表字段约定
|
||||||
|
|
||||||
|
| 字段 | 必有 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `album` | 是 | 服务端按登录态校验后返回,客户端不再逐任务查询相册 |
|
||||||
|
| `album.user_phone` | 否 | 返回原始手机号时客户端脱敏;没有时返回空字符串,不返回多种类型 |
|
||||||
|
| `outputs` | 是 | 各输出类型的计划生成数量 |
|
||||||
|
| `preview_images` | 是 | 最多返回 3 项,允许为空数组 |
|
||||||
|
| `progress` | 是 | 六种子任务数量必须满足进度恒等式 |
|
||||||
|
| `estimated_finish_at` | 否 | 无法估算或已终态时返回 `null` |
|
||||||
|
| `failure_summary` | 否 | 任务级可展示摘要;`failed` 时必有,其他状态存在失败子任务时建议返回 |
|
||||||
|
| `next_cursor` | 是 | 无下一页时为 `null` |
|
||||||
|
| `has_more` | 是 | 与 `next_cursor` 语义一致 |
|
||||||
|
|
||||||
|
`estimated_finish_at` 是动态估算而非 SLA。估算变化时允许更新;客户端只展示最新值,不做倒计时承诺。
|
||||||
|
|
||||||
|
`failure_summary` 不代替逐照片失败原因。存在多个不同失败原因时,列表建议返回“N 张照片处理失败,点击查看原因”;只有单一且简短的原因时才直接返回该原因。建议限制在 60 个中文字符以内。
|
||||||
|
|
||||||
|
## 11. 获取 AI 修图任务详情
|
||||||
|
|
||||||
|
### 11.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?ai_retouch_batch_id=9521
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ai_retouch_batch_id` | 是 | int | 大于 0 的 AI 修图批次 ID |
|
||||||
|
|
||||||
|
接口不要求客户端再传 `user_equity_travel_id`。后端应从登录态和任务归属完成权限校验,并在响应中返回相册信息。
|
||||||
|
|
||||||
|
### 11.2 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"scope": "batch",
|
||||||
|
"status": "processing",
|
||||||
|
"album": {
|
||||||
|
"id": 88,
|
||||||
|
"name": "旅拍相册",
|
||||||
|
"user_phone": "13812348000",
|
||||||
|
"cover_url": "https://cdn.example.com/albums/88/cover.jpg"
|
||||||
|
},
|
||||||
|
"source_count": 4,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"count": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 2,
|
||||||
|
"processing": 1,
|
||||||
|
"succeeded": 5,
|
||||||
|
"failed": 1,
|
||||||
|
"canceled": 0
|
||||||
|
},
|
||||||
|
"quota_settlement": {
|
||||||
|
"status": "partially_settled",
|
||||||
|
"reserved_units": 8,
|
||||||
|
"consumed_units": 5,
|
||||||
|
"released_units": 1,
|
||||||
|
"cover_units": 0
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"target_id": 30101,
|
||||||
|
"source_material": {
|
||||||
|
"id": 2031,
|
||||||
|
"file_name": "IMG_8291.JPG",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2031_thumb.jpg"
|
||||||
|
},
|
||||||
|
"input_material_ids": [2031],
|
||||||
|
"output_type": "refined",
|
||||||
|
"template": {
|
||||||
|
"id": 11,
|
||||||
|
"name": "自然通透"
|
||||||
|
},
|
||||||
|
"status": "succeeded",
|
||||||
|
"result_asset": {
|
||||||
|
"id": 9201,
|
||||||
|
"material_id": 2031,
|
||||||
|
"url": "https://cdn.example.com/albums/88/2031_refined_v2.jpg",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2031_refined_v2_thumb.jpg"
|
||||||
|
},
|
||||||
|
"error": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:26:18.000Z",
|
||||||
|
"finished_at": "2026-08-14T06:28:40.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_id": 30102,
|
||||||
|
"source_material": {
|
||||||
|
"id": 2032,
|
||||||
|
"file_name": "IMG_8292.JPG",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2032_thumb.jpg"
|
||||||
|
},
|
||||||
|
"input_material_ids": [2032],
|
||||||
|
"output_type": "atmosphere",
|
||||||
|
"template": {
|
||||||
|
"id": 21,
|
||||||
|
"name": "暖阳氛围"
|
||||||
|
},
|
||||||
|
"status": "processing",
|
||||||
|
"result_asset": null,
|
||||||
|
"error": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:29:02.000Z",
|
||||||
|
"finished_at": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_id": 30103,
|
||||||
|
"source_material": {
|
||||||
|
"id": 2033,
|
||||||
|
"file_name": "IMG_8293.JPG",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/2033_thumb.jpg"
|
||||||
|
},
|
||||||
|
"input_material_ids": [2033],
|
||||||
|
"output_type": "refined",
|
||||||
|
"template": {
|
||||||
|
"id": 11,
|
||||||
|
"name": "自然通透"
|
||||||
|
},
|
||||||
|
"status": "failed",
|
||||||
|
"result_asset": null,
|
||||||
|
"error": {
|
||||||
|
"code": "AI_PROVIDER_TIMEOUT",
|
||||||
|
"message": "AI服务处理超时,请重新修图",
|
||||||
|
"retryable": true
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:27:10.000Z",
|
||||||
|
"finished_at": "2026-08-14T06:29:30.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_id": 30109,
|
||||||
|
"source_material": null,
|
||||||
|
"input_material_ids": [2031, 2032, 2033, 2034],
|
||||||
|
"output_type": "cover",
|
||||||
|
"template": {
|
||||||
|
"id": 31,
|
||||||
|
"name": "旅拍拼贴"
|
||||||
|
},
|
||||||
|
"status": "queued",
|
||||||
|
"result_asset": null,
|
||||||
|
"error": null,
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"estimated_finish_at": "2026-08-14T06:32:00.000Z",
|
||||||
|
"created_at": "2026-08-14T06:26:12.123Z",
|
||||||
|
"started_at": "2026-08-14T06:26:18.000Z",
|
||||||
|
"finished_at": null,
|
||||||
|
"duration_seconds": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 失败明细
|
||||||
|
|
||||||
|
失败子任务的 `error` 结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "AI_PROVIDER_TIMEOUT",
|
||||||
|
"message": "AI服务处理超时,请重新修图",
|
||||||
|
"retryable": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
约定:
|
||||||
|
|
||||||
|
- `status = failed` 时 `error` 和非空 `error.message` 必须返回,不能只返回错误码。
|
||||||
|
- `message` 必须是经过后端转换、可直接展示给用户的中文文案,建议不超过 120 个中文字符。
|
||||||
|
- `code` 用于客户端判断与联调排查,不直接展示。
|
||||||
|
- 不得返回供应商密钥、内部请求、堆栈、服务器路径或原始异常。
|
||||||
|
- `retryable` 只表达业务上是否允许重新提交,本期详情页不直接发起批量重试。
|
||||||
|
- `queued`、`processing`、`succeeded` 时 `error` 固定返回 `null`,不要返回空对象。
|
||||||
|
- 同一来源照片的精修和氛围感必须分别返回自己的 `error`,客户端不通过素材级公共错误猜测具体失败输出。
|
||||||
|
|
||||||
|
### 11.4 额度结算
|
||||||
|
|
||||||
|
| 状态 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `reserved` | 已预占,尚无子任务完成 |
|
||||||
|
| `partially_settled` | 部分预占已转为消耗或释放 |
|
||||||
|
| `settled` | 所有额度完成结算 |
|
||||||
|
|
||||||
|
必须满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reserved_units >= consumed_units + released_units
|
||||||
|
```
|
||||||
|
|
||||||
|
终态时应满足:
|
||||||
|
|
||||||
|
```text
|
||||||
|
reserved_units = consumed_units + released_units
|
||||||
|
```
|
||||||
|
|
||||||
|
封面不消耗额度,`cover_units` 固定为 0。
|
||||||
|
|
||||||
|
## 12. 接口错误码
|
||||||
|
|
||||||
|
| HTTP | 业务码 | 场景 | 客户端行为 |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 400 | `INVALID_RETOUCH_STATUS_GROUP` | 列表筛选参数错误 | 回退“全部”并记录联调日志 |
|
||||||
|
| 400 | `INVALID_CURSOR` | 游标无效或过期 | 清空游标并重新加载第一页 |
|
||||||
|
| 400 | `INVALID_RETOUCH_BATCH_ID` | 任务 ID 非法 | 进入任务列表 |
|
||||||
|
| 401 | `UNAUTHORIZED` | 登录态失效 | 走现有重新登录流程,登录后继续待处理路由 |
|
||||||
|
| 404 | `AI_RETOUCH_JOB_NOT_FOUND` | 不存在、已失效或无权访问 | 提示“任务不存在或已失效”,进入任务列表 |
|
||||||
|
| 429 | `TOO_MANY_REQUESTS` | 刷新过于频繁 | 停止本轮轮询,按服务端建议时间重试 |
|
||||||
|
| 500 | `AI_RETOUCH_JOB_QUERY_FAILED` | 服务端异常 | 保留已有数据并提供重试 |
|
||||||
|
|
||||||
|
权限不足建议统一返回 404,避免泄露其他账号任务是否存在。
|
||||||
|
|
||||||
|
## 13. AI 修图推送协议
|
||||||
|
|
||||||
|
### 13.1 发送条件
|
||||||
|
|
||||||
|
- 只在任务第一次从非终态进入 `succeeded`、`partially_succeeded` 或 `failed` 时发送。
|
||||||
|
- `canceled` 默认不发送通知。
|
||||||
|
- 同一 `ai_retouch_batch_id` 只发送一次终态推送。
|
||||||
|
- 后端应通过事务字段或唯一记录保证幂等,例如 `terminal_push_sent_at`。
|
||||||
|
- 设备没有有效极光 Registration ID 时不影响任务结算;用户仍可在任务中心查看结果。
|
||||||
|
|
||||||
|
### 13.2 统一业务结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "AI修图已完成",
|
||||||
|
"content": "「旅拍相册」的9张结果已生成,点击查看。",
|
||||||
|
"msg_id": 2014,
|
||||||
|
"type": 14,
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"status": "succeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段约定:
|
||||||
|
|
||||||
|
| 字段 | 必有 | 类型 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `type` | 是 | int | 固定为 `14` |
|
||||||
|
| `msg_id` | 是 | int | 后端业务消息 ID,不是极光 `_j_msgid` |
|
||||||
|
| `data` | 是 | object | 不得发送为 JSON 字符串 |
|
||||||
|
| `ai_retouch_batch_id` | 是 | int | 大于 0;点击详情的主键 |
|
||||||
|
| `user_equity_travel_id` | 是 | int | 用于降级列表和联调排查 |
|
||||||
|
| `status` | 是 | string | 仅允许三个会推送的终态值 |
|
||||||
|
|
||||||
|
### 13.3 通知文案
|
||||||
|
|
||||||
|
| 状态 | 标题 | 正文模板 |
|
||||||
|
|---|---|---|
|
||||||
|
| `succeeded` | AI修图已完成 | `「{相册名}」的{成功数}张结果已生成,点击查看。` |
|
||||||
|
| `partially_succeeded` | AI修图部分完成 | `{成功数}张成功,{失败数}张失败,点击查看详情。` |
|
||||||
|
| `failed` | AI修图未完成 | `本次任务处理失败,点击查看原因。` |
|
||||||
|
|
||||||
|
相册名称为空时,正文使用“本次AI修图任务”,不得出现空书名号。
|
||||||
|
|
||||||
|
### 13.4 iOS 示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aps": {
|
||||||
|
"alert": {
|
||||||
|
"title": "AI修图已完成",
|
||||||
|
"body": "「旅拍相册」的9张结果已生成,点击查看。"
|
||||||
|
},
|
||||||
|
"sound": "default"
|
||||||
|
},
|
||||||
|
"msg_id": 2014,
|
||||||
|
"type": 14,
|
||||||
|
"data": {
|
||||||
|
"ai_retouch_batch_id": 9521,
|
||||||
|
"user_equity_travel_id": 88,
|
||||||
|
"status": "succeeded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Android 的 `extras` 中放入同一业务对象;字段名、字段类型和业务含义必须与 iOS 一致。
|
||||||
|
|
||||||
|
## 14. 推送点击路由
|
||||||
|
|
||||||
|
### 14.1 路由规则
|
||||||
|
|
||||||
|
```text
|
||||||
|
收到通知点击
|
||||||
|
→ 解析业务 type
|
||||||
|
→ type != 14:继续走现有类型路由
|
||||||
|
→ type = 14:解析 data.ai_retouch_batch_id
|
||||||
|
→ 合法:打开 AI 修图任务详情
|
||||||
|
→ 缺失/非法:打开 AI 修图任务列表
|
||||||
|
→ 详情请求 404:提示后返回任务列表
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.2 生命周期
|
||||||
|
|
||||||
|
- 前台、后台点击和冷启动使用同一套 `type + data` 解析规则。
|
||||||
|
- 未登录时暂存 `ai_retouch_batch_id`,登录成功且主 Tab 建立后继续路由。
|
||||||
|
- 账号切换或退出登录时清除上一账号尚未执行的待处理路由。
|
||||||
|
- 同一系统通知的 request identifier 只处理一次。
|
||||||
|
- 客户端不得从标题或正文解析任务 ID。
|
||||||
|
|
||||||
|
### 14.3 向后兼容
|
||||||
|
|
||||||
|
- 旧客户端无法识别 `type = 14` 时按既有默认逻辑进入消息中心,不应崩溃。
|
||||||
|
- 新客户端收到 `type = 10` 时仍按人工修图处理,不进入 AI 修图任务中心。
|
||||||
|
- 对 `data` 中新增的未知字段,客户端应忽略。
|
||||||
|
|
||||||
|
### 14.4 站内消息详情入口
|
||||||
|
|
||||||
|
- `type = 14` 的站内消息详情页在消息正文下方展示品牌蓝主按钮“查看任务详情”。
|
||||||
|
- 消息 `extra_data.ai_retouch_batch_id` 合法时进入对应任务详情;兼容 `extra_data.data.ai_retouch_batch_id` 包装结构。
|
||||||
|
- 任务 ID 缺失、无法解析或小于等于 0 时,按钮仍保留,点击后降级进入 AI 修图任务列表。
|
||||||
|
- `type = 10` 人工修图及其他类型消息不展示该入口,原有消息详情行为保持不变。
|
||||||
|
- 底部“删除并返回”继续作为独立的破坏性操作,不承担业务跳转职责。
|
||||||
|
|
||||||
|
## 15. 数据、安全与一致性
|
||||||
|
|
||||||
|
- 列表和详情只能返回当前登录账号有权限查看的任务。
|
||||||
|
- 后端从登录态确定账号范围,不接受客户端传入用户 ID 扩大查询范围。
|
||||||
|
- 手机号仅用于业务识别,客户端统一脱敏展示。
|
||||||
|
- 缩略图和结果 URL 应使用受控 CDN 地址;如需签名,过期时间应覆盖合理浏览时长。
|
||||||
|
- 任务状态和所有子任务状态必须在同一份一致性快照中返回。
|
||||||
|
- 推送发送失败不得回滚已完成的 AI 任务或额度结算。
|
||||||
|
- 已删除且不再允许客户端展示的素材,不应继续通过任务详情暴露原图或结果 URL。
|
||||||
|
- 如果任务已整体不可见,详情统一返回 `AI_RETOUCH_JOB_NOT_FOUND`。
|
||||||
|
|
||||||
|
## 16. 验收标准
|
||||||
|
|
||||||
|
### 16.1 页面
|
||||||
|
|
||||||
|
- 能从相册管理页进入当前账号全部 AI 修图任务列表,新增相册页不展示该入口。
|
||||||
|
- 四种筛选与六种状态映射正确。
|
||||||
|
- 进行中任务显示真实完成数量;有 ETA 才显示预计完成时间。
|
||||||
|
- 详情可展示任务摘要、相册信息、额度和逐输出明细。
|
||||||
|
- 单照片、单输出失败时,失败原因展示在对应明细内部,不需要用户从任务级摘要猜测失败对象。
|
||||||
|
- 后端原因超过两行时可查看完整内容;原因缺失时有稳定兜底文案。
|
||||||
|
- 成功结果可进入对应照片结果,失败项可进入所属相册。
|
||||||
|
- 页面离开或任务终态后停止轮询。
|
||||||
|
|
||||||
|
### 16.2 接口
|
||||||
|
|
||||||
|
- 两个提交接口返回稳定且可跳转的 `ai_retouch_batch_id`。
|
||||||
|
- 游标分页在任务新增和状态更新期间不重复、不漏项。
|
||||||
|
- 进度数量满足恒等式,任务终态与子任务状态一致。
|
||||||
|
- ETA 缺失时返回 `null`,不返回空字符串或 0 时间。
|
||||||
|
- `status = failed` 的子任务必有非空 `error.message`,且详情能够按照片和输出类型准确展示。
|
||||||
|
- 失败信息不包含供应商、堆栈、服务器路径等内部实现。
|
||||||
|
|
||||||
|
### 16.3 推送
|
||||||
|
|
||||||
|
- `type = 14` 未被其他业务占用,后端类型名称映射完整。
|
||||||
|
- `type = 10` 人工修图和 `type = 14` AI 修图互不影响。
|
||||||
|
- 成功、部分成功、失败各只发送一次终态通知。
|
||||||
|
- 前台、后台、冷启动、未登录和重复点击均符合路由规则。
|
||||||
|
- 缺少任务 ID、非法 ID、任务失效时均安全降级到任务列表。
|
||||||
|
|
||||||
|
## 17. 本期不做
|
||||||
|
|
||||||
|
- 从任务中心取消 AI 修图任务。
|
||||||
|
- 一键批量重试失败子任务。
|
||||||
|
- 展示 AI 供应商、内部错误堆栈或链路日志。
|
||||||
|
- 展示同一照片的历史修图版本。
|
||||||
|
- 为 ETA 提供 SLA 倒计时承诺。
|
||||||
|
- 修改 `type = 10` 人工修图协议。
|
||||||
|
|
||||||
|
## 18. 设计稿生成说明
|
||||||
|
|
||||||
|
两张设计稿使用内置 ImageGen 生成,参考现有 AI 修图 V2 的浅色相册管理页面、旅行摄影素材和状态视觉语言。
|
||||||
|
|
||||||
|
- 任务列表:853 × 1844,展示进行中、已完成、部分完成三种代表性卡片。
|
||||||
|
- 任务详情 V2:852 × 1846,展示处理中的任务、6/9 进度、ETA、消息通知说明,以及单照片“生成失败 + 失败原因”的内联状态。
|
||||||
|
- 设计稿用于产品和研发对齐;实际 UIKit 实现应使用项目内颜色、字体、SnapKit 和 SF Symbols,不把设计稿作为页面背景切图。
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
# AI 修图接口文档
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
| # | 方法 | 路径 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `GET` | `/api/yf-handset-app/photog/travel-album/material-list` | 获取相册网格素材和预览初始数据 |
|
||||||
|
| 2 | `GET` | `/api/yf-handset-app/photog/travel-album/material-info` | 获取/刷新当前原图及关联图 |
|
||||||
|
| 3 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-options` | 获取 AI 模板、选择规则和剩余额度 |
|
||||||
|
| 4 | `POST` | `/api/yf-handset-app/photog/travel-album/ai-retouch` | 提交首次修图或重新修图任务 |
|
||||||
|
| 5 | `GET` | `/api/yf-handset-app/photog/travel-album/ai-retouch-job-info` | 查询 AI 修图任务进度和结果 |
|
||||||
|
| 6 | `POST` | `/api/yf-handset-app/photog/travel-album/delete-material` | 单张/批量删除素材及关联图 |
|
||||||
|
|
||||||
|
## 接口详情
|
||||||
|
|
||||||
|
### 获取相册素材列表
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/material-list?user_equity_travel_id=88&limit=30&cursor=opaque_cursor&sort=created_at_desc
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 必填 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `user_equity_travel_id` | 是 | 相册 ID |
|
||||||
|
| `limit` | 否 | 默认 30,最大 100 |
|
||||||
|
| `cursor` | 否 | 服务端返回的不透明游标,首页不传 |
|
||||||
|
| `sort` | 否 | `created_at_desc` 或 `created_at_asc` |
|
||||||
|
| `material_type` | 否 | `original`、`cover`;不传表示全部 |
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "2031",
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"material_type": "original",
|
||||||
|
"original_asset": {
|
||||||
|
"id": "asset_8001",
|
||||||
|
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
|
||||||
|
"file_name": "IMG_1024.jpg",
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
|
"file_size": 4821931,
|
||||||
|
"width": 4032,
|
||||||
|
"height": 3024,
|
||||||
|
"version": 1,
|
||||||
|
"created_at": "2026-08-09T01:00:00.000Z"
|
||||||
|
},
|
||||||
|
"display_status": "uploaded",
|
||||||
|
"variant_slots": [],
|
||||||
|
"revision": 7,
|
||||||
|
"created_at": "2026-08-09T01:00:00.000Z",
|
||||||
|
"updated_at": "2026-08-11T07:30:18.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"next_cursor": "next_opaque_cursor",
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只返回网格顶层素材:原图和独立封面。
|
||||||
|
- 原图如存在关联图,`variant_slots` 包含预览页 Tab 初始数据。
|
||||||
|
- 建议使用游标分页,因为 AI 任务完成时可能实时插入封面素材。
|
||||||
|
|
||||||
|
### 获取/刷新单个原图项目
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/material-info?user_equity_travel_id=88&material_id=2031
|
||||||
|
```
|
||||||
|
|
||||||
|
查询参数 `user_equity_travel_id` 和 `material_id` 均必填。响应的 `data` 直接返完整 `material_project`,字段与列表 Item 完全一致。该接口供预览页刷新和提交后定向更新使用。
|
||||||
|
|
||||||
|
如 `material_id` 已删除,返回 `404 MATERIAL_NOT_FOUND`。
|
||||||
|
|
||||||
|
### 获取 AI 修图选项
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_travel_id=88&scope=batch&source_count=4
|
||||||
|
```
|
||||||
|
|
||||||
|
| `scope` | 使用场景 |
|
||||||
|
|---|---|
|
||||||
|
| `batch` | 网格多选 AI 修图,或预览页尚无 AI 结果 Tab 时首次修图 |
|
||||||
|
| `all_variants` | 预览页已有 AI 结果 Tab;当前为原图、精修后或氛围感均使用此范围 |
|
||||||
|
| `refined_only` | 仅重修精修结果的接口能力,当前客户端无独立入口 |
|
||||||
|
| `atmosphere_only` | 仅重修氛围感结果的接口能力,当前客户端无独立入口 |
|
||||||
|
|
||||||
|
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"scope": "batch",
|
||||||
|
"source_count": 4,
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"title": "原图精修",
|
||||||
|
"required": true,
|
||||||
|
"selection_mode": "single",
|
||||||
|
"quota_cost": {
|
||||||
|
"mode": "per_source",
|
||||||
|
"units": 1
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"id": "tpl_refined_12",
|
||||||
|
"name": "清透精修",
|
||||||
|
"preview_url": "https://cdn.example.com/templates/refined_12.jpg",
|
||||||
|
"before_url": "https://cdn.example.com/templates/refined_12_before.jpg",
|
||||||
|
"after_url": "https://cdn.example.com/templates/refined_12_after.jpg",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"title": "氛围感修图",
|
||||||
|
"required": false,
|
||||||
|
"selection_mode": "single",
|
||||||
|
"quota_cost": {
|
||||||
|
"mode": "per_source",
|
||||||
|
"units": 1
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"id": "tpl_atmosphere_06",
|
||||||
|
"name": "暖阳",
|
||||||
|
"preview_url": "https://cdn.example.com/templates/atmosphere_06.jpg",
|
||||||
|
"before_url": "https://cdn.example.com/templates/atmosphere_06_before.jpg",
|
||||||
|
"after_url": "https://cdn.example.com/templates/atmosphere_06_after.jpg",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"title": "封面风格模板",
|
||||||
|
"required": true,
|
||||||
|
"selection_mode": "single",
|
||||||
|
"quota_cost": {
|
||||||
|
"mode": "per_job",
|
||||||
|
"units": 0
|
||||||
|
},
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"id": "tpl_cover_03",
|
||||||
|
"name": "旅行画册",
|
||||||
|
"preview_url": "https://cdn.example.com/templates/cover_03.jpg",
|
||||||
|
"before_url": "https://cdn.example.com/templates/cover_03_before.jpg",
|
||||||
|
"after_url": "https://cdn.example.com/templates/cover_03_after.jpg",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quota": {
|
||||||
|
"unit": "retouch_time",
|
||||||
|
"remaining": 12,
|
||||||
|
"reserved": 3,
|
||||||
|
"display_text": "剩余 12 次",
|
||||||
|
"updated_at": "2026-08-11T07:29:58.000Z"
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"minimum_source_count": 1,
|
||||||
|
"maximum_source_count": 50,
|
||||||
|
"cover_minimum_source_count": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
|
||||||
|
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
|
||||||
|
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
|
||||||
|
- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
|
||||||
|
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
|
||||||
|
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
|
||||||
|
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
|
||||||
|
- `quota.remaining` 是扣除其他未完成任务已预占数量后、当前立即可用的额度;弹窗底部使用此值显示“剩余 N 次”。
|
||||||
|
- 额度文案位于模板选择弹窗的固定底部区域,不随中间模板列表滚动。
|
||||||
|
- `quota.reserved` 是当前用户其他未完成任务的预占额度,仅用于解释账户状态。
|
||||||
|
- 客户端预估本次消耗时,对已选输出求和:`per_source` 类型为 `source_count * units`,`per_job` 类型为 `units`。封面始终为 0。
|
||||||
|
- 本文档默认精修和氛围感每生成一张各消耗 1 次。如实际商业规则变更,后端只需调整 `quota_cost`,客户端不需改接口。
|
||||||
|
|
||||||
|
### 创建 AI 修图任务
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/yf-handset-app/photog/travel-album/ai-retouch
|
||||||
|
Idempotency-Key: 9A7820CE-78AB-45C6-9E0F-5DF268995379
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4.1 网格批量修图
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "batch",
|
||||||
|
"source_material_ids": ["2031", "2032", "2033", "2034"],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"template_id": "tpl_refined_12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"template_id": "tpl_atmosphere_06"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cover",
|
||||||
|
"template_id": "tpl_cover_03"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4.2 预览页已有 AI 结果 Tab
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "all_variants",
|
||||||
|
"source_material_ids": ["2031"],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"template_id": "tpl_atmosphere_09"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
已有精修或氛围感结果时,无论当前位于原图、精修后还是氛围感 Tab,精修和氛围感模板均为选填,但至少选择一种。`outputs` 只传本次选中的结果类型;缺失表示“本次不处理”,不表示删除或覆盖对应的已有结果。
|
||||||
|
|
||||||
|
#### 5.4.3 只重新精修
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "refined_only",
|
||||||
|
"source_material_ids": ["2031"],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"template_id": "tpl_refined_18"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4.4 只重新生成氛围感
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "atmosphere_only",
|
||||||
|
"source_material_ids": ["2031"],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"template_id": "tpl_atmosphere_11"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.4.5 提交校验矩阵
|
||||||
|
|
||||||
|
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|
||||||
|
|---|---:|---|---|---|
|
||||||
|
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
|
||||||
|
| `all_variants` | 必须为 1 | 可选(与氛围感至少一项) | 可选(与精修至少一项) | 禁止 |
|
||||||
|
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
|
||||||
|
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
|
||||||
|
|
||||||
|
另外:
|
||||||
|
|
||||||
|
- `source_material_ids` 必须去重,且全部为 `original` 类型。
|
||||||
|
- 模板必须启用、类型匹配,且适用于相册所属景区。
|
||||||
|
- 后端必须使用提交时的最新额度计费规则重新计算消耗,不信任客户端的预估数值。
|
||||||
|
- 封面输出不论首次生成、重试或任务结果如何,额度消耗始终为 0。
|
||||||
|
- 每个输出槽位均采用 `replace_on_success`:成功后替换旧版本,失败保留旧版本。
|
||||||
|
- 批量选中已有 AI 结果的原图时,允许重新生成,只覆盖本次 `outputs` 列出的类型。
|
||||||
|
- 同一原图、同一槽位已有 `queued/processing` 任务时,整次请求返回 `409 RETOUCH_TARGET_BUSY`,并列出冲突项。
|
||||||
|
|
||||||
|
#### 5.4.6 提交响应
|
||||||
|
|
||||||
|
HTTP 状态码:`202 Accepted`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "AI 修图任务已提交",
|
||||||
|
"data": {
|
||||||
|
"job": {
|
||||||
|
"id": "job_01K2E4",
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "all_variants",
|
||||||
|
"status": "queued",
|
||||||
|
"source_material_ids": ["2031"],
|
||||||
|
"progress": {
|
||||||
|
"total": 2,
|
||||||
|
"queued": 2,
|
||||||
|
"processing": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"failed": 0
|
||||||
|
},
|
||||||
|
"created_at": "2026-08-11T07:30:15.123Z",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null
|
||||||
|
},
|
||||||
|
"quota_reservation": {
|
||||||
|
"before_remaining": 12,
|
||||||
|
"reserved_units": 2,
|
||||||
|
"remaining_after_reservation": 10,
|
||||||
|
"cover_units": 0
|
||||||
|
},
|
||||||
|
"affected_projects": [
|
||||||
|
{
|
||||||
|
"id": "2031",
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"material_type": "original",
|
||||||
|
"original_asset": {
|
||||||
|
"id": "asset_8001",
|
||||||
|
"url": "https://cdn.example.com/albums/88/IMG_1024.jpg",
|
||||||
|
"thumbnail_url": "https://cdn.example.com/albums/88/IMG_1024_thumb.jpg",
|
||||||
|
"file_name": "IMG_1024.jpg",
|
||||||
|
"mime_type": "image/jpeg",
|
||||||
|
"file_size": 4821931,
|
||||||
|
"width": 4032,
|
||||||
|
"height": 3024,
|
||||||
|
"version": 1,
|
||||||
|
"created_at": "2026-08-09T01:00:00.000Z"
|
||||||
|
},
|
||||||
|
"display_status": "pending",
|
||||||
|
"variant_slots": [
|
||||||
|
{
|
||||||
|
"type": "refined",
|
||||||
|
"asset": null,
|
||||||
|
"latest_generation": {
|
||||||
|
"job_id": "job_01K2E4",
|
||||||
|
"status": "queued",
|
||||||
|
"is_replacement": false,
|
||||||
|
"template": {
|
||||||
|
"id": "tpl_refined_15",
|
||||||
|
"name": "自然精修"
|
||||||
|
},
|
||||||
|
"requested_at": "2026-08-11T07:30:15.123Z",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "atmosphere",
|
||||||
|
"asset": null,
|
||||||
|
"latest_generation": {
|
||||||
|
"job_id": "job_01K2E4",
|
||||||
|
"status": "queued",
|
||||||
|
"is_replacement": false,
|
||||||
|
"template": {
|
||||||
|
"id": "tpl_atmosphere_09",
|
||||||
|
"name": "暖阳"
|
||||||
|
},
|
||||||
|
"requested_at": "2026-08-11T07:30:15.123Z",
|
||||||
|
"started_at": null,
|
||||||
|
"finished_at": null,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revision": 8,
|
||||||
|
"created_at": "2026-08-09T01:00:00.000Z",
|
||||||
|
"updated_at": "2026-08-11T07:30:15.123Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
必须返回 `job.id`、`quota_reservation` 和更新后的 `affected_projects`。客户端用 `remaining_after_reservation` 立即更新剩余额度,并更新 Cell 状态;已有 `asset` 的重修 Tab 可同步显示处理态,首次生成且 `asset = null` 的槽位不显示 Tab。
|
||||||
|
|
||||||
|
### 查询 AI 修图任务
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/travel-album/ai-retouch-job-info?user_equity_travel_id=88&job_id=job_01K2E4
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": "job_01K2E4",
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"scope": "batch",
|
||||||
|
"status": "partially_succeeded",
|
||||||
|
"source_material_ids": ["2031", "2032", "2033", "2034"],
|
||||||
|
"progress": {
|
||||||
|
"total": 9,
|
||||||
|
"queued": 0,
|
||||||
|
"processing": 0,
|
||||||
|
"succeeded": 8,
|
||||||
|
"failed": 1
|
||||||
|
},
|
||||||
|
"quota_settlement": {
|
||||||
|
"status": "settled",
|
||||||
|
"reserved_units": 8,
|
||||||
|
"consumed_units": 7,
|
||||||
|
"released_units": 1,
|
||||||
|
"cover_units": 0
|
||||||
|
},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"source_material_id": "2031",
|
||||||
|
"output_type": "refined",
|
||||||
|
"status": "succeeded",
|
||||||
|
"result_asset_id": "asset_9201",
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_material_id": "2032",
|
||||||
|
"output_type": "atmosphere",
|
||||||
|
"status": "failed",
|
||||||
|
"result_asset_id": null,
|
||||||
|
"error": {
|
||||||
|
"code": "AI_PROVIDER_TIMEOUT",
|
||||||
|
"message": "AI 服务处理超时,请重试",
|
||||||
|
"retryable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_material_id": null,
|
||||||
|
"output_type": "cover",
|
||||||
|
"status": "succeeded",
|
||||||
|
"result_material_id": "cover_301",
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-08-11T07:30:15.123Z",
|
||||||
|
"started_at": "2026-08-11T07:30:18.000Z",
|
||||||
|
"finished_at": "2026-08-11T07:33:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 整体状态 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `queued` | 已受理,尚无子任务开始 |
|
||||||
|
| `processing` | 至少一个子任务正在执行,且尚未全部完成 |
|
||||||
|
| `succeeded` | 所有子任务成功 |
|
||||||
|
| `partially_succeeded` | 子任务全部结束,且既有成功也有失败 |
|
||||||
|
| `failed` | 子任务全部结束,没有任何成功结果 |
|
||||||
|
| `canceled` | 任务因素材删除或管理操作被取消 |
|
||||||
|
|
||||||
|
额度结算规则:
|
||||||
|
|
||||||
|
- 任务提交成功时先预占所有非封面子任务的额度,封面不进入预占。
|
||||||
|
- 每个精修/氛围感子任务成功后,将对应预占转为实际消耗。
|
||||||
|
- 子任务失败或取消时释放对应预占。因此部分成功示例中,预占 8 次、成功消耗 7 次、失败退回 1 次。
|
||||||
|
- `quota_settlement.status` 取值为 `reserved | partially_settled | settled`。
|
||||||
|
|
||||||
|
客户端如只关心当前预览图,优先调用单素材接口;本接口主要用于批量进度、问题排查和任务详情。
|
||||||
|
|
||||||
|
### 批量删除素材
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/yf-handset-app/photog/travel-album/delete-material
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_equity_travel_id": "88",
|
||||||
|
"material_ids": ["2031", "2032", "cover_301"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
为兼容旧客户端,原单张请求体继续有效:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "2031"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
新请求使用 `user_equity_travel_id + material_ids`,旧请求使用 `id`,两组参数不同时传入。
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "删除成功",
|
||||||
|
"data": {
|
||||||
|
"deleted_material_ids": ["2031", "2032", "cover_301"],
|
||||||
|
"deleted_variant_asset_ids": ["asset_9101", "asset_9102"],
|
||||||
|
"canceled_job_ids": ["job_01K2E4"],
|
||||||
|
"oss_cleanup_status": "scheduled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
语义:
|
||||||
|
|
||||||
|
- 传入原图 ID:级联软删除精修图、氛围感图,并取消该原图的未完成子任务。
|
||||||
|
- 传入独立封面 ID:只删除该封面素材。
|
||||||
|
- 删除原图不级联删除以它为输入之一的已生成封面。
|
||||||
|
- 数据库业务删除采用一个事务,默认全部成功或全部失败。
|
||||||
|
- 任一 ID 不属于当前相册或无权删除时,整次请求失败。已删除的 ID 可视为幂等成功。
|
||||||
|
- OSS 文件清理异步执行,不影响业务删除成功。
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# AI 修图需求文档
|
||||||
|
|
||||||
|
> 本文档描述旅拍相册中 AI 修图的产品需求和业务规则。后端接口建议见同目录《AI 修图接口文档》。
|
||||||
|
|
||||||
|
## 1. 目标与范围
|
||||||
|
|
||||||
|
- 相册管理页面展示用户已在其他流程上传到 OSS、并已在后端登记的图片素材。
|
||||||
|
- 用户可从网格页批量发起 AI 修图,也可从单图预览页首次修图或重新修图。
|
||||||
|
- 精修图和氛围感图是某张原图的“关联图”;封面图是相册内的独立素材,不归属于任何一张原图。
|
||||||
|
- AI 生成为异步长流程,页面必须能分辨已提交、排队、处理、成功和失败。
|
||||||
|
|
||||||
|
## 2. 核心概念
|
||||||
|
|
||||||
|
### 2.1 原图项目
|
||||||
|
|
||||||
|
一张原图与它的精修图、氛围感图共同组成一个原图项目。原图的素材 ID 是项目的稳定标识,OSS URL 不作为业务标识。
|
||||||
|
|
||||||
|
### 2.2 关联图
|
||||||
|
|
||||||
|
- `refined`:精修图,一张原图最多保留一个当前有效版本。
|
||||||
|
- `atmosphere`:氛围感图,一张原图最多保留一个当前有效版本。
|
||||||
|
- 关联图不作为独立 Cell 出现在相册网格中,只在该原图的预览页 Tab 中展示。
|
||||||
|
|
||||||
|
### 2.3 封面图
|
||||||
|
|
||||||
|
- 一次选择不少于 4 张原图时,必须选择封面模板。
|
||||||
|
- 封面模板使用本次选中的多张原图生成封面图。
|
||||||
|
- 封面图不关联某张原图,生成成功后作为 `cover` 类型的独立素材出现在相册网格中。
|
||||||
|
- 删除参与生成封面的原图时,不级联删除已生成的独立封面图。
|
||||||
|
|
||||||
|
## 3. 相册管理页
|
||||||
|
|
||||||
|
### 3.1 网格列表
|
||||||
|
|
||||||
|
- 网格展示原图和独立封面图,不直接展示精修图、氛围感图。
|
||||||
|
- 列表需支持稳定分页。AI 任务执行期间可能插入新的封面素材,不应造成重复或漏项。
|
||||||
|
- 点击原图 Cell 进入图片预览页,可横向滑动切换其他原图项目。
|
||||||
|
|
||||||
|
### 3.2 Cell 修图状态
|
||||||
|
|
||||||
|
Cell 左上角展示稳定的修图状态:
|
||||||
|
|
||||||
|
| 状态 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 已上传 | 原图已登记,从未提交 AI 修图,当前也没有任务 |
|
||||||
|
| 待处理 | AI 任务已提交,正在排队 |
|
||||||
|
| 修图中 | AI 任务已开始执行 |
|
||||||
|
| AI 已修 | 最新任务已成功,存在有效 AI 结果 |
|
||||||
|
| 失败 | 最新任务执行失败,可能仍保留上一版成功结果 |
|
||||||
|
|
||||||
|
状态以后端返回的枚举代码为准,客户端自行映射中文文案,不依赖可变的中文状态名。
|
||||||
|
|
||||||
|
### 3.3 批量 AI 修图
|
||||||
|
|
||||||
|
1. 用户进入多选模式并选择一张或多张原图。
|
||||||
|
2. 点击底部“AI 修图”,弹出模板选择页。
|
||||||
|
3. 精修模板必选且单选。
|
||||||
|
4. 氛围感模板可选且单选,已选时可取消。
|
||||||
|
5. 选中原图数量小于 4 时不展示封面模板。
|
||||||
|
6. 选中原图数量大于等于 4 时展示封面模板,且必须单选一个。
|
||||||
|
7. 提交后,每张原图分别生成精修图,选了氛围感模板时再分别生成氛围感图。
|
||||||
|
8. 选了封面模板时,整个批次额外生成独立封面图。
|
||||||
|
9. 提交成功指后端已受理任务,不代表图片已生成。
|
||||||
|
|
||||||
|
### 3.4 修图额度
|
||||||
|
|
||||||
|
- 模板选择弹窗的底部固定显示当前用户可用的 AI 修图剩余额度,文案例如“剩余 12 次”,不随模板列表滚动消失。
|
||||||
|
- 精修和氛围感生成会消耗修图额度;封面模板生成不消耗任何修图额度。
|
||||||
|
- 本需求默认按生成结果张数计费:每张原图的精修结果消耗 1 次,每张原图的氛围感结果消耗 1 次,封面结果消耗 0 次。最终单价由后端返回,客户端不硬编码。
|
||||||
|
- 重新修图与首次修图使用相同的额度规则。
|
||||||
|
- 后端受理任务时预占本次所需额度,避免用户并发提交造成超额。
|
||||||
|
- 只对最终生成成功的精修/氛围感结果扣减额度;失败或取消的子任务释放对应预占额度。
|
||||||
|
- 如剩余可用额度不足,提交按钮禁用并显示明确提示;后端提交接口仍必须做最终校验。
|
||||||
|
|
||||||
|
### 3.5 批量删除
|
||||||
|
|
||||||
|
- 用户多选原图后点击“删除”,删除选中原图及各自的精修图、氛围感图,并取消关联的未完成任务。已有任务记录保留用于审计和排查,但不再对客户端展示已删除素材。
|
||||||
|
- 已生成的独立封面图不在级联删除范围内,除非用户明确选中该封面素材本身。
|
||||||
|
- 批量删除应只调用一次后端接口,后端应返回整体或逐项结果,不由客户端循环调用单删接口。
|
||||||
|
|
||||||
|
## 4. 图片预览页
|
||||||
|
|
||||||
|
### 4.1 横向切换
|
||||||
|
|
||||||
|
- 从网格点击某张原图进入预览页。
|
||||||
|
- 横向滑动在相册内的不同原图项目之间切换。
|
||||||
|
- 切换到新的原图项目时,默认显示其“原图”。
|
||||||
|
|
||||||
|
### 4.2 底部操作
|
||||||
|
|
||||||
|
页面底部固定显示三个按钮:
|
||||||
|
|
||||||
|
- AI 修图
|
||||||
|
- 删除
|
||||||
|
- 刷新
|
||||||
|
|
||||||
|
### 4.3 关联图 Tab
|
||||||
|
|
||||||
|
- 如果当前原图没有任何已生成的关联图,不显示 Tab;首次生成任务排队或处理期间仍不显示。
|
||||||
|
- 存在至少一张已成功的关联图时显示 Tab。
|
||||||
|
- Tab 最多包含“原图”、“精修后”、“氛围感”;关联图 Tab 只在对应图片已生成时显示。
|
||||||
|
- 点击 Tab 切换当前原图项目内的展示图片,不切换原图项目。
|
||||||
|
- 某个已有结果的 Tab 重新提交修图后,仍可展示上一版图片,同时通过该结果槽位的任务状态展示“待处理/修图中/失败”。
|
||||||
|
- 首次生成尚无图片 URL 时,通过原图 Cell 状态和项目任务状态表达进度,不创建空 Tab。
|
||||||
|
|
||||||
|
### 4.4 预览页删除
|
||||||
|
|
||||||
|
- 删除的对象始终是当前原图项目,而不是当前 Tab 上的单张关联图。
|
||||||
|
- 删除原图及其所有关联图,并取消关联的未完成任务;历史任务记录保留用于审计。
|
||||||
|
- 删除成功后显示下一个原图项目;如果删除的是末项,则回退上一项;无剩余项时关闭预览页。
|
||||||
|
|
||||||
|
### 4.5 预览页 AI 修图
|
||||||
|
|
||||||
|
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|
||||||
|
|---|---|---|
|
||||||
|
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
|
||||||
|
| 已显示 Tab,当前为“原图” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
|
| 已显示 Tab,当前为“精修后” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
|
| 已显示 Tab,当前为“氛围感” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||||
|
|
||||||
|
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
|
||||||
|
|
||||||
|
### 4.6 刷新
|
||||||
|
|
||||||
|
- 点击“刷新”只获取当前显示原图项目的最新数据,包括原图、关联图、各结果槽位的任务状态和整体修图状态。
|
||||||
|
- 不应为刷新一张图重新拉取整个分页列表。
|
||||||
|
- 服务端对已替换的图片应返回新的资源版本或新 URL,避免 CDN/客户端缓存继续显示旧图。
|
||||||
|
|
||||||
|
## 5. 异步任务规则
|
||||||
|
|
||||||
|
- 每次提交返回唯一 `job_id`、服务端接收时间和当前状态。
|
||||||
|
- 每次提交同时返回额度预占数、提交前剩余额度和预占后可用额度。
|
||||||
|
- 任务状态至少包含:`queued`、`processing`、`succeeded`、`partially_succeeded`、`failed`、`canceled`。
|
||||||
|
- 精修、氛围感和封面子任务可以独立成功或失败,服务端需保留逐原图、逐结果类型的状态。
|
||||||
|
- 客户端重试同一次提交时不得创建重复任务,由幂等键保证。
|
||||||
|
- 同一原图、同一结果类型存在未完成任务时,服务端拒绝再次提交,避免旧任务晚完成后覆盖新结果。
|
||||||
|
|
||||||
|
## 6. 删除与数据一致性
|
||||||
|
|
||||||
|
- 删除原图项目需在一个后端业务操作中完成:标记原图删除、标记关联图删除、取消未完成任务。
|
||||||
|
- 数据库状态成功后再异步清理 OSS 文件,不应因 OSS 删除失败导致客户端删除失败。
|
||||||
|
- AI 回调必须检查任务和素材是否已删除或已被新版本替代,过期回调不得恢复已删除素材或覆盖新结果。
|
||||||
|
|
||||||
|
## 7. 异常与提示
|
||||||
|
|
||||||
|
- 模板加载失败:保留页面并允许重试。
|
||||||
|
- 模板已下线:提交时返回明确错误码,客户端刷新模板列表。
|
||||||
|
- 素材不存在、不属于当前相册或不是原图:整次提交不受理,并返回问题素材 ID。
|
||||||
|
- 数量不满足封面规则:返回稳定错误码和最小数量。
|
||||||
|
- 任务部分成功:保留已成功结果,失败槽位提供错误码、错误信息和是否可重试。
|
||||||
|
- 网络超时:客户端使用原幂等键重试,不可产生两个任务。
|
||||||
|
|
||||||
|
## 8. 权限与校验
|
||||||
|
|
||||||
|
- 后端必须从登录态校验用户对相册、原图和模板的访问权,不信任客户端传入的用户 ID、景区 ID 或批次 ID。
|
||||||
|
- 模板是否适用于相册所属景区,由后端根据相册 ID 推导和校验。
|
||||||
|
- 后端负责校验精修、氛围感和封面模板的必选/可选规则,客户端校验只用于交互提示。
|
||||||
|
|
||||||
|
## 9. 默认产品决策
|
||||||
|
|
||||||
|
为使接口和异步覆盖行为可实现,本文档采用以下默认决策:
|
||||||
|
|
||||||
|
1. 重新修图不先删旧图;新图成功后才原子切换,失败仍保留旧图。
|
||||||
|
2. 在“原图” Tab 重新修图时,若用户未选氛围感模板,保留现有氛围感图,只替换精修图。
|
||||||
|
3. 封面图只在生成成功后插入网格;生成前由所选原图的修图状态和任务详情表达进度。
|
||||||
|
4. 每个关联图类型只对外暴露一个当前有效版本;历史版本是否长期保留属于后端存储策略,不在当前客户端功能中展示。
|
||||||
|
|
||||||
|
> 第 2 条是对“氛围感模板可选”的安全解释:未选不等于删除旧结果。如产品希望未选时删除旧氛围感图,应增加明确的“移除氛围感”操作,不建议让后端根据字段缺失隐式删除。
|
||||||
@@ -29,6 +29,9 @@ protocol TravelAlbumServing {
|
|||||||
isPurchased: Int?
|
isPurchased: Int?
|
||||||
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
|
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
|
||||||
|
|
||||||
|
/// 拉取单个相册素材及其最新关联图片信息。
|
||||||
|
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial
|
||||||
|
|
||||||
/// 上传并登记旅拍相册素材。
|
/// 上传并登记旅拍相册素材。
|
||||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
|
||||||
|
|
||||||
@@ -38,11 +41,30 @@ protocol TravelAlbumServing {
|
|||||||
/// 删除旅拍相册。
|
/// 删除旅拍相册。
|
||||||
func deleteAlbum(id: Int) async throws
|
func deleteAlbum(id: Int) async throws
|
||||||
|
|
||||||
/// 删除旅拍相册素材。
|
/// 批量删除旅拍相册素材。
|
||||||
func deleteMaterial(id: Int) async throws
|
func batchDeleteMaterials(ids: [Int]) async throws
|
||||||
|
|
||||||
/// 拉取相册小程序码。
|
/// 拉取相册小程序码。
|
||||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
||||||
|
|
||||||
|
/// 拉取当前景区可用的 AI 修图模板。
|
||||||
|
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||||
|
|
||||||
|
/// 提交相册素材 AI 修图任务。
|
||||||
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||||
|
|
||||||
|
/// 提交单张素材重新修图任务。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||||
|
|
||||||
|
/// 拉取当前账号的 AI 修图任务列表。
|
||||||
|
func aiRetouchJobList(
|
||||||
|
statusGroup: TravelAlbumAIJobFilter,
|
||||||
|
limit: Int,
|
||||||
|
cursor: String?
|
||||||
|
) async throws -> TravelAlbumAIJobListResponse
|
||||||
|
|
||||||
|
/// 拉取指定 AI 修图任务详情。
|
||||||
|
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -113,6 +135,20 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取单个相册素材及其最新关联图片信息。
|
||||||
|
func materialInfo(userEquityTravelId: Int, materialId: Int) async throws -> TravelAlbumMaterial {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/material-info",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "user_equity_travel_id", value: String(userEquityTravelId)),
|
||||||
|
URLQueryItem(name: "material_id", value: String(materialId)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 上传并登记旅拍相册素材。
|
/// 上传并登记旅拍相册素材。
|
||||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
||||||
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request))
|
||||||
@@ -138,10 +174,14 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除旅拍相册素材。
|
/// 批量删除旅拍相册素材。
|
||||||
func deleteMaterial(id: Int) async throws {
|
func batchDeleteMaterials(ids: [Int]) async throws {
|
||||||
let _: EmptyPayload = try await client.send(
|
let _: EmptyPayload = try await client.send(
|
||||||
APIRequest(method: .post, path: "\(basePath)/delete-material", body: TravelAlbumIDRequest(id: id))
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "\(basePath)/batch-delete-material",
|
||||||
|
body: TravelAlbumBatchDeleteMaterialRequest(ids: ids)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,9 +195,64 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取当前景区可用的 AI 修图模板。
|
||||||
|
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/ai-retouch-templates",
|
||||||
|
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交相册素材 AI 修图任务并返回任务摘要。
|
||||||
|
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||||
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交重新修图任务并返回任务摘要。
|
||||||
|
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||||
|
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取当前账号的 AI 修图任务列表。
|
||||||
|
func aiRetouchJobList(
|
||||||
|
statusGroup: TravelAlbumAIJobFilter,
|
||||||
|
limit: Int = 20,
|
||||||
|
cursor: String? = nil
|
||||||
|
) async throws -> TravelAlbumAIJobListResponse {
|
||||||
|
var queryItems = [
|
||||||
|
URLQueryItem(name: "status_group", value: statusGroup.rawValue),
|
||||||
|
URLQueryItem(name: "limit", value: String(min(50, max(1, limit)))),
|
||||||
|
]
|
||||||
|
if let cursor = cursor?.trimmingCharacters(in: .whitespacesAndNewlines), !cursor.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "cursor", value: cursor))
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "\(basePath)/ai-retouch-job-list", queryItems: queryItems)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取指定 AI 修图任务详情。
|
||||||
|
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "\(basePath)/ai-retouch-job-info",
|
||||||
|
queryItems: [URLQueryItem(name: "ai_retouch_batch_id", value: String(batchId))]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旅拍相册 ID 请求体。
|
/// 旅拍相册 ID 请求体。
|
||||||
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
private struct TravelAlbumIDRequest: Encodable, Sendable {
|
||||||
let id: Int
|
let id: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 旅拍相册素材批量删除请求体。
|
||||||
|
private struct TravelAlbumBatchDeleteMaterialRequest: Encodable, Sendable {
|
||||||
|
let ids: [Int]
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图任务筛选分组,对应任务列表接口的 `status_group`。
|
||||||
|
enum TravelAlbumAIJobFilter: String, CaseIterable, Sendable, Hashable {
|
||||||
|
case all
|
||||||
|
case inProgress = "in_progress"
|
||||||
|
case completed
|
||||||
|
case failed
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .all: "全部"
|
||||||
|
case .inProgress: "进行中"
|
||||||
|
case .completed: "已完成"
|
||||||
|
case .failed: "失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务及子任务状态;未知值安全降级,避免新增后端状态导致整页解码失败。
|
||||||
|
enum TravelAlbumAIJobStatus: Sendable, Hashable {
|
||||||
|
case queued
|
||||||
|
case processing
|
||||||
|
case succeeded
|
||||||
|
case partiallySucceeded
|
||||||
|
case failed
|
||||||
|
case canceled
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
init(rawValue: String) {
|
||||||
|
switch rawValue {
|
||||||
|
case "queued": self = .queued
|
||||||
|
case "processing": self = .processing
|
||||||
|
case "succeeded": self = .succeeded
|
||||||
|
case "partially_succeeded": self = .partiallySucceeded
|
||||||
|
case "failed": self = .failed
|
||||||
|
case "canceled": self = .canceled
|
||||||
|
default: self = .unknown(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawValue: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: "queued"
|
||||||
|
case .processing: "processing"
|
||||||
|
case .succeeded: "succeeded"
|
||||||
|
case .partiallySucceeded: "partially_succeeded"
|
||||||
|
case .failed: "failed"
|
||||||
|
case .canceled: "canceled"
|
||||||
|
case .unknown(let value): value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isInProgress: Bool { self == .queued || self == .processing }
|
||||||
|
var isTerminal: Bool { !isInProgress && !isUnknown }
|
||||||
|
private var isUnknown: Bool { if case .unknown = self { true } else { false } }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .queued: "排队中"
|
||||||
|
case .processing: "修图中"
|
||||||
|
case .succeeded: "已完成"
|
||||||
|
case .partiallySucceeded: "部分完成"
|
||||||
|
case .failed: "失败"
|
||||||
|
case .canceled: "已取消"
|
||||||
|
case .unknown: "状态更新中"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIJobStatus: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图输出类型。
|
||||||
|
enum TravelAlbumAIJobOutputType: Sendable, Hashable {
|
||||||
|
case refined
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
case unknown(String)
|
||||||
|
|
||||||
|
init(rawValue: String) {
|
||||||
|
switch rawValue {
|
||||||
|
case "refined": self = .refined
|
||||||
|
case "atmosphere": self = .atmosphere
|
||||||
|
case "cover": self = .cover
|
||||||
|
default: self = .unknown(rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "原图精修"
|
||||||
|
case .atmosphere: "氛围感"
|
||||||
|
case .cover: "封面"
|
||||||
|
case .unknown: "其他结果"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewKind: TravelAlbumPreviewAssetKind? {
|
||||||
|
switch self {
|
||||||
|
case .refined: .retouched
|
||||||
|
case .atmosphere: .atmosphere
|
||||||
|
case .cover: .cover
|
||||||
|
case .unknown: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIJobOutputType: Decodable {
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(rawValue: (try? decoder.singleValueContainer().decode(String.self)) ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图子任务数量进度。
|
||||||
|
struct TravelAlbumAIJobProgress: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let total: Int
|
||||||
|
let queued: Int
|
||||||
|
let processing: Int
|
||||||
|
let succeeded: Int
|
||||||
|
let failed: Int
|
||||||
|
let canceled: Int
|
||||||
|
|
||||||
|
var completed: Int { min(total, succeeded + failed + canceled) }
|
||||||
|
var fraction: Double { total > 0 ? min(1, Double(completed) / Double(total)) : 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图提交成功后返回的任务摘要。
|
||||||
|
struct TravelAlbumAIJobSubmission: Decodable, Sendable, Equatable {
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let createdAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case status, progress
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务所属相册摘要。
|
||||||
|
struct TravelAlbumAIJobAlbum: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let userPhone: String
|
||||||
|
let coverURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name
|
||||||
|
case userPhone = "user_phone"
|
||||||
|
case coverURL = "cover_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务计划输出数量。
|
||||||
|
struct TravelAlbumAIJobOutput: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let type: TravelAlbumAIJobOutputType
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务列表缩略图。
|
||||||
|
struct TravelAlbumAIJobPreviewImage: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let materialId: Int
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case materialId = "material_id"
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务列表项。
|
||||||
|
struct TravelAlbumAIJobSummary: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
var id: Int { aiRetouchBatchId }
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let scope: String
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let album: TravelAlbumAIJobAlbum
|
||||||
|
let sourceCount: Int
|
||||||
|
let outputs: [TravelAlbumAIJobOutput]
|
||||||
|
let previewImages: [TravelAlbumAIJobPreviewImage]
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let estimatedFinishAt: String?
|
||||||
|
let failureSummary: String?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case scope, status, album, outputs, progress
|
||||||
|
case sourceCount = "source_count"
|
||||||
|
case previewImages = "preview_images"
|
||||||
|
case estimatedFinishAt = "estimated_finish_at"
|
||||||
|
case failureSummary = "failure_summary"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayFailureSummary: String? {
|
||||||
|
guard status == .failed || status == .partiallySucceeded || progress.failed > 0 else { return nil }
|
||||||
|
let value = failureSummary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return value.isEmpty ? "部分照片处理失败,点击查看原因" : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务游标分页响应。
|
||||||
|
struct TravelAlbumAIJobListResponse: Decodable, Sendable, Equatable {
|
||||||
|
let items: [TravelAlbumAIJobSummary]
|
||||||
|
let nextCursor: String?
|
||||||
|
let hasMore: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case items
|
||||||
|
case nextCursor = "next_cursor"
|
||||||
|
case hasMore = "has_more"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图额度结算信息。
|
||||||
|
struct TravelAlbumAIJobQuotaSettlement: Decodable, Sendable, Equatable {
|
||||||
|
let status: String
|
||||||
|
let reservedUnits: Int
|
||||||
|
let consumedUnits: Int
|
||||||
|
let releasedUnits: Int
|
||||||
|
let coverUnits: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case status
|
||||||
|
case reservedUnits = "reserved_units"
|
||||||
|
case consumedUnits = "consumed_units"
|
||||||
|
case releasedUnits = "released_units"
|
||||||
|
case coverUnits = "cover_units"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图目标的来源素材。
|
||||||
|
struct TravelAlbumAIJobSourceMaterial: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let fileName: String
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case fileName = "file_name"
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图目标使用的模板摘要。
|
||||||
|
struct TravelAlbumAIJobTemplate: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图成功结果资源。
|
||||||
|
struct TravelAlbumAIJobResultAsset: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let materialId: Int
|
||||||
|
let url: String
|
||||||
|
let thumbnailURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case materialId = "material_id"
|
||||||
|
case url
|
||||||
|
case thumbnailURL = "thumbnail_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可直接展示给用户的 AI 修图失败信息。
|
||||||
|
struct TravelAlbumAIJobError: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let code: String
|
||||||
|
let message: String
|
||||||
|
let retryable: Bool
|
||||||
|
|
||||||
|
var displayMessage: String {
|
||||||
|
let value = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return value.isEmpty ? "处理失败,请前往相册重新修图" : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个输出目标的处理明细。
|
||||||
|
struct TravelAlbumAIJobTarget: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
var id: Int { targetId }
|
||||||
|
let targetId: Int
|
||||||
|
let sourceMaterial: TravelAlbumAIJobSourceMaterial?
|
||||||
|
let inputMaterialIds: [Int]
|
||||||
|
let outputType: TravelAlbumAIJobOutputType
|
||||||
|
let template: TravelAlbumAIJobTemplate?
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let resultAsset: TravelAlbumAIJobResultAsset?
|
||||||
|
let error: TravelAlbumAIJobError?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case targetId = "target_id"
|
||||||
|
case sourceMaterial = "source_material"
|
||||||
|
case inputMaterialIds = "input_material_ids"
|
||||||
|
case outputType = "output_type"
|
||||||
|
case template, status, error
|
||||||
|
case resultAsset = "result_asset"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayFailureMessage: String? {
|
||||||
|
guard status == .failed else { return nil }
|
||||||
|
return error?.displayMessage ?? "处理失败,请前往相册重新修图"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务完整详情。
|
||||||
|
struct TravelAlbumAIJobDetail: Decodable, Sendable, Equatable {
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let scope: String
|
||||||
|
let status: TravelAlbumAIJobStatus
|
||||||
|
let album: TravelAlbumAIJobAlbum
|
||||||
|
let sourceCount: Int
|
||||||
|
let outputs: [TravelAlbumAIJobOutput]
|
||||||
|
let progress: TravelAlbumAIJobProgress
|
||||||
|
let quotaSettlement: TravelAlbumAIJobQuotaSettlement
|
||||||
|
let targets: [TravelAlbumAIJobTarget]
|
||||||
|
let estimatedFinishAt: String?
|
||||||
|
let createdAt: String
|
||||||
|
let startedAt: String?
|
||||||
|
let finishedAt: String?
|
||||||
|
let durationSeconds: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case scope, status, album, outputs, progress, targets
|
||||||
|
case sourceCount = "source_count"
|
||||||
|
case quotaSettlement = "quota_settlement"
|
||||||
|
case estimatedFinishAt = "estimated_finish_at"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case finishedAt = "finished_at"
|
||||||
|
case durationSeconds = "duration_seconds"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务日期展示工具。
|
||||||
|
enum TravelAlbumAIJobDateFormatter {
|
||||||
|
private static let internetFormatter = ISO8601DateFormatter()
|
||||||
|
private static let preciseFormatter: ISO8601DateFormatter = {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func date(_ value: String?) -> Date? {
|
||||||
|
guard let value, !value.isEmpty else { return nil }
|
||||||
|
return preciseFormatter.date(from: value) ?? internetFormatter.date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func display(_ value: String?) -> String {
|
||||||
|
guard let date = date(value) else { return "--" }
|
||||||
|
return date.formatted(.dateTime.month().day().hour().minute())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func time(_ value: String?) -> String {
|
||||||
|
guard let date = date(value) else { return "--" }
|
||||||
|
return date.formatted(.dateTime.hour().minute())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
let fileSize: Int
|
let fileSize: Int
|
||||||
let coverUrl: String
|
let coverUrl: String
|
||||||
let isPurchased: Bool
|
let isPurchased: Bool
|
||||||
|
let aiRetouchStatus: Int
|
||||||
|
let aiRetouchStatusName: String
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let aiRefinedURL: String
|
||||||
|
let aiAtmosphereURL: String
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
let updatedAt: String
|
let updatedAt: String
|
||||||
|
|
||||||
@@ -142,10 +147,42 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
case fileSize = "file_size"
|
case fileSize = "file_size"
|
||||||
case coverUrl = "cover_url"
|
case coverUrl = "cover_url"
|
||||||
case isPurchased = "is_purchased"
|
case isPurchased = "is_purchased"
|
||||||
|
case aiRetouchStatus = "ai_retouch_status"
|
||||||
|
case aiRetouchStatusName = "ai_retouch_status_name"
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case aiRefinedURL = "ai_refined_url"
|
||||||
|
case aiAtmosphereURL = "ai_atmosphere_url"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从素材接口解码;AI 修图扩展字段缺失、为空或类型异常时使用安全默认值。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(Int.self, forKey: .id)
|
||||||
|
userEquityTravelId = try container.decode(Int.self, forKey: .userEquityTravelId)
|
||||||
|
status = try container.decode(Int.self, forKey: .status)
|
||||||
|
orderNumber = try container.decode(String.self, forKey: .orderNumber)
|
||||||
|
userId = try container.decode(Int.self, forKey: .userId)
|
||||||
|
fileName = try container.decode(String.self, forKey: .fileName)
|
||||||
|
fileType = try container.decode(Int.self, forKey: .fileType)
|
||||||
|
fileUrl = try container.decode(String.self, forKey: .fileUrl)
|
||||||
|
fileSize = try container.decode(Int.self, forKey: .fileSize)
|
||||||
|
coverUrl = try container.decode(String.self, forKey: .coverUrl)
|
||||||
|
isPurchased = try container.decode(Bool.self, forKey: .isPurchased)
|
||||||
|
aiRetouchStatus = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchStatus)) ?? 0
|
||||||
|
aiRetouchStatusName = (
|
||||||
|
try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName)
|
||||||
|
) ?? ""
|
||||||
|
aiRetouchBatchId = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchBatchId)) ?? 0
|
||||||
|
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
|
||||||
|
aiAtmosphereURL = (
|
||||||
|
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
|
||||||
|
) ?? ""
|
||||||
|
createdAt = try container.decode(String.self, forKey: .createdAt)
|
||||||
|
updatedAt = try container.decode(String.self, forKey: .updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: Int = 0,
|
id: Int = 0,
|
||||||
userEquityTravelId: Int = 0,
|
userEquityTravelId: Int = 0,
|
||||||
@@ -158,6 +195,11 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
fileSize: Int = 0,
|
fileSize: Int = 0,
|
||||||
coverUrl: String = "",
|
coverUrl: String = "",
|
||||||
isPurchased: Bool = false,
|
isPurchased: Bool = false,
|
||||||
|
aiRetouchStatus: Int = 0,
|
||||||
|
aiRetouchStatusName: String = "",
|
||||||
|
aiRetouchBatchId: Int = 0,
|
||||||
|
aiRefinedURL: String = "",
|
||||||
|
aiAtmosphereURL: String = "",
|
||||||
createdAt: String = "",
|
createdAt: String = "",
|
||||||
updatedAt: String = ""
|
updatedAt: String = ""
|
||||||
) {
|
) {
|
||||||
@@ -172,11 +214,70 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
|||||||
self.fileSize = fileSize
|
self.fileSize = fileSize
|
||||||
self.coverUrl = coverUrl
|
self.coverUrl = coverUrl
|
||||||
self.isPurchased = isPurchased
|
self.isPurchased = isPurchased
|
||||||
|
self.aiRetouchStatus = aiRetouchStatus
|
||||||
|
self.aiRetouchStatusName = aiRetouchStatusName
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
|
self.aiRefinedURL = aiRefinedURL
|
||||||
|
self.aiAtmosphereURL = aiAtmosphereURL
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。
|
||||||
|
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
||||||
|
case purchased
|
||||||
|
case pending
|
||||||
|
case processing
|
||||||
|
case retouched
|
||||||
|
case cover
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册素材网格角标展示内容。
|
||||||
|
struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
|
||||||
|
let kind: TravelAlbumMaterialBadgeKind
|
||||||
|
let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumMaterial {
|
||||||
|
/// 生成购买状态角标;购买状态与修图状态互不覆盖。
|
||||||
|
var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
|
isPurchased
|
||||||
|
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
|
||||||
|
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||||
|
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
switch aiRetouchStatus {
|
||||||
|
case 1:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: .pending,
|
||||||
|
text: statusName.isEmpty ? "待处理" : statusName
|
||||||
|
)
|
||||||
|
case 2:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: .processing,
|
||||||
|
text: statusName.isEmpty ? "修图中" : statusName
|
||||||
|
)
|
||||||
|
case 3:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: statusName == "AI封面" ? .cover : .retouched,
|
||||||
|
text: statusName.isEmpty ? "AI已修" : statusName
|
||||||
|
)
|
||||||
|
case 4:
|
||||||
|
return TravelAlbumMaterialBadgePresentation(
|
||||||
|
kind: .failed,
|
||||||
|
text: statusName.isEmpty ? "失败" : statusName
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
|
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
|
||||||
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
||||||
let name: String
|
let name: String
|
||||||
@@ -247,6 +348,188 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
|
||||||
|
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
|
||||||
|
case refined
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
|
||||||
|
/// 模板分组展示标题。
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .refined: "原图精修"
|
||||||
|
case .atmosphere: "氛围感修图"
|
||||||
|
case .cover: "封面风格"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板页工作流,明确区分首次批量修图与单张结果覆盖重修。
|
||||||
|
enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
||||||
|
/// 对一个相册内的原始素材发起首次 AI 修图。
|
||||||
|
case initial(albumId: Int, materialIds: [Int])
|
||||||
|
/// 对单张素材的既有 AI 结果发起覆盖重修。
|
||||||
|
case reretouch(materialId: Int, batchId: Int, type: TravelAlbumAIReretouchType)
|
||||||
|
|
||||||
|
/// 当前页面需要展示的模板分组。
|
||||||
|
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
|
||||||
|
switch self {
|
||||||
|
case .initial(_, let materialIds):
|
||||||
|
var categories: [TravelAlbumAIRetouchTemplateCategory] = [.refined, .atmosphere]
|
||||||
|
if materialIds.count >= 4 { categories.append(.cover) }
|
||||||
|
return categories
|
||||||
|
case .reretouch(_, _, .refined):
|
||||||
|
return [.refined]
|
||||||
|
case .reretouch(_, _, .atmosphere):
|
||||||
|
return [.atmosphere]
|
||||||
|
case .reretouch(_, _, .all):
|
||||||
|
return [.refined, .atmosphere]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。
|
||||||
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .initial:
|
||||||
|
return category == .atmosphere
|
||||||
|
case .reretouch(_, _, .all):
|
||||||
|
return category == .refined || category == .atmosphere
|
||||||
|
case .reretouch:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工作流目标是否满足接口的最小参数要求。
|
||||||
|
var isValid: Bool {
|
||||||
|
switch self {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
return albumId > 0 && !materialIds.isEmpty
|
||||||
|
case .reretouch(let materialId, let batchId, _):
|
||||||
|
return materialId > 0 && batchId > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板,包含业务 ID、展示名称、卡片预览图及前后对比图片地址。
|
||||||
|
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let previewURL: String
|
||||||
|
let beforeURL: String
|
||||||
|
let afterURL: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case previewURL = "preview_url"
|
||||||
|
case beforeURL = "before_url"
|
||||||
|
case afterURL = "after_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建 AI 修图模板。
|
||||||
|
init(
|
||||||
|
id: Int,
|
||||||
|
name: String,
|
||||||
|
previewURL: String,
|
||||||
|
beforeURL: String = "",
|
||||||
|
afterURL: String = ""
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.previewURL = previewURL
|
||||||
|
self.beforeURL = beforeURL
|
||||||
|
self.afterURL = afterURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码模板;前后对比字段缺失或为 null 时按空字符串兼容旧接口响应。
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(Int.self, forKey: .id)
|
||||||
|
name = try container.decode(String.self, forKey: .name)
|
||||||
|
previewURL = try container.decode(String.self, forKey: .previewURL)
|
||||||
|
beforeURL = (try? container.decodeIfPresent(String.self, forKey: .beforeURL)) ?? ""
|
||||||
|
afterURL = (try? container.decodeIfPresent(String.self, forKey: .afterURL)) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图模板接口响应,按原图、氛围感和封面风格分组。
|
||||||
|
struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable {
|
||||||
|
let refinedTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
let atmosphereTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
let coverTemplates: [TravelAlbumAIRetouchTemplate]
|
||||||
|
let remainingQuota: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case refinedTemplates = "refined_templates"
|
||||||
|
case atmosphereTemplates = "atmosphere_templates"
|
||||||
|
case coverTemplates = "cover_templates"
|
||||||
|
case remainingQuota = "remaining_quota"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建分组模板响应,默认各组为空。
|
||||||
|
init(
|
||||||
|
refinedTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
|
atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
|
coverTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||||
|
remainingQuota: Int = 0
|
||||||
|
) {
|
||||||
|
self.refinedTemplates = refinedTemplates
|
||||||
|
self.atmosphereTemplates = atmosphereTemplates
|
||||||
|
self.coverTemplates = coverTemplates
|
||||||
|
self.remainingQuota = max(0, remainingQuota)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解码模板和剩余额度;旧响应缺少额度时按零处理,避免误提交付费任务。
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
refinedTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .refinedTemplates)
|
||||||
|
atmosphereTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .atmosphereTemplates)
|
||||||
|
coverTemplates = try container.decode([TravelAlbumAIRetouchTemplate].self, forKey: .coverTemplates)
|
||||||
|
remainingQuota = max(0, try container.decodeIfPresent(Int.self, forKey: .remainingQuota) ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交 AI 修图任务的请求参数。
|
||||||
|
struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
||||||
|
let userEquityTravelId: Int
|
||||||
|
let materialIds: [Int]
|
||||||
|
let refinedTemplateId: Int
|
||||||
|
let atmosphereTemplateId: Int?
|
||||||
|
let coverTemplateId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userEquityTravelId = "user_equity_travel_id"
|
||||||
|
case materialIds = "material_ids"
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
|
case atmosphereTemplateId = "atmosphere_template_id"
|
||||||
|
case coverTemplateId = "cover_template_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重新修图类型,决定后端覆盖的 AI 结果及必需模板字段。
|
||||||
|
enum TravelAlbumAIReretouchType: Int, Encodable, Sendable, Equatable {
|
||||||
|
case refined = 1
|
||||||
|
case atmosphere = 2
|
||||||
|
case all = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交单张素材重新修图的最小请求参数。
|
||||||
|
struct TravelAlbumAIReretouchRequest: Encodable, Sendable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let type: TravelAlbumAIReretouchType
|
||||||
|
let refinedTemplateId: Int?
|
||||||
|
let atmosphereTemplateId: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case aiRetouchBatchId = "ai_retouch_batch_id"
|
||||||
|
case type
|
||||||
|
case refinedTemplateId = "refined_template_id"
|
||||||
|
case atmosphereTemplateId = "atmosphere_template_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 旅拍相册展示格式化工具。
|
/// 旅拍相册展示格式化工具。
|
||||||
enum TravelAlbumDisplayFormatter {
|
enum TravelAlbumDisplayFormatter {
|
||||||
/// 脱敏手机号。
|
/// 脱敏手机号。
|
||||||
@@ -277,4 +560,22 @@ enum TravelAlbumDisplayFormatter {
|
|||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将服务端创建时间格式化为摘要卡使用的 `yyyy/MM/dd HH:mm`。
|
||||||
|
static func creationTimeText(_ text: String) -> String {
|
||||||
|
guard !text.isEmpty else { return "--" }
|
||||||
|
let normalized = text.replacingOccurrences(of: "T", with: " ")
|
||||||
|
guard normalized.count >= 16 else { return normalized }
|
||||||
|
return String(normalized.prefix(16)).replacingOccurrences(of: "-", with: "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算相册摘要卡封面,按相册、素材缩略图、素材原图顺序回退。
|
||||||
|
static func albumCoverURL(album: TravelAlbum?, materials: [TravelAlbumMaterial]) -> String {
|
||||||
|
if let coverURL = album?.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines), !coverURL.isEmpty {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
guard let first = materials.first else { return "" }
|
||||||
|
let materialCover = first.coverUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return materialCover.isEmpty ? first.fileUrl.trimmingCharacters(in: .whitespacesAndNewlines) : materialCover
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumPreviewModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 前后图片对比页所需的通用内容,与具体入口和 UI 框架解耦。
|
||||||
|
struct BeforeAfterComparisonContent: Sendable, Equatable {
|
||||||
|
let beforeURL: String
|
||||||
|
let afterURL: String
|
||||||
|
let beforeLabel: String
|
||||||
|
let afterLabel: String
|
||||||
|
|
||||||
|
/// 创建前后对比内容,默认使用 AI 修图模块的中文角标。
|
||||||
|
init(
|
||||||
|
beforeURL: String,
|
||||||
|
afterURL: String,
|
||||||
|
beforeLabel: String = "原图",
|
||||||
|
afterLabel: String = "效果图"
|
||||||
|
) {
|
||||||
|
self.beforeURL = beforeURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.afterURL = afterURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.beforeLabel = beforeLabel
|
||||||
|
self.afterLabel = afterLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两张图片地址均存在时才允许进入对比页。
|
||||||
|
var isValid: Bool {
|
||||||
|
!beforeURL.isEmpty && !afterURL.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册预览页横向滑动策略,由调用方通过配置注入。
|
||||||
|
enum TravelAlbumPreviewSwipeMode: Sendable, Equatable {
|
||||||
|
/// 横滑只切换原图项目,关联图通过 Tab 切换。
|
||||||
|
case projectsOnly
|
||||||
|
/// 横滑依次切换当前项目的关联图,并在边界进入相邻项目原图。
|
||||||
|
case includeVariants
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 相册预览页配置。
|
||||||
|
struct TravelAlbumPreviewConfiguration: Sendable {
|
||||||
|
let swipeMode: TravelAlbumPreviewSwipeMode
|
||||||
|
|
||||||
|
/// 创建预览配置,默认只按原图项目分页。
|
||||||
|
init(swipeMode: TravelAlbumPreviewSwipeMode = .projectsOnly) {
|
||||||
|
self.swipeMode = swipeMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览图片类型,顺序同时决定 Tab 与关联图浏览顺序。
|
||||||
|
enum TravelAlbumPreviewAssetKind: Int, CaseIterable, Sendable, Hashable {
|
||||||
|
case original
|
||||||
|
case retouched
|
||||||
|
case atmosphere
|
||||||
|
case cover
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .original: "原图"
|
||||||
|
case .retouched: "精修后"
|
||||||
|
case .atmosphere: "氛围感"
|
||||||
|
case .cover: "封面"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页使用的单张图片信息,与后端关联图字段解耦。
|
||||||
|
struct TravelAlbumPreviewAsset: Identifiable, Sendable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let kind: TravelAlbumPreviewAssetKind
|
||||||
|
let fileURL: String
|
||||||
|
let coverURL: String
|
||||||
|
let fileName: String
|
||||||
|
let fileSize: Int
|
||||||
|
|
||||||
|
/// 实际用于展示的地址,优先使用原图地址。
|
||||||
|
var displayURL: String {
|
||||||
|
let trimmedFileURL = fileURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmedFileURL.isEmpty ? coverURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedFileURL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 横滑预览地址,优先使用体积更小的封面图以降低解码开销。
|
||||||
|
var previewURL: String {
|
||||||
|
let trimmedCoverURL = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmedCoverURL.isEmpty ? fileURL.trimmingCharacters(in: .whitespacesAndNewlines) : trimmedCoverURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一张原图及其所有关联图片组成的预览项目。
|
||||||
|
struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||||
|
let originalMaterialId: Int
|
||||||
|
let aiRetouchBatchId: Int
|
||||||
|
let assets: [TravelAlbumPreviewAsset]
|
||||||
|
|
||||||
|
var id: Int { originalMaterialId }
|
||||||
|
|
||||||
|
/// 按产品约定顺序返回实际存在的图片。
|
||||||
|
var orderedAssets: [TravelAlbumPreviewAsset] {
|
||||||
|
TravelAlbumPreviewAssetKind.allCases.compactMap(asset(for:))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否存在原图之外的关联图片。
|
||||||
|
var hasVariants: Bool {
|
||||||
|
orderedAssets.contains { $0.kind != .original }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定类型的图片。
|
||||||
|
func asset(for kind: TravelAlbumPreviewAssetKind) -> TravelAlbumPreviewAsset? {
|
||||||
|
assets.first { $0.kind == kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 为精修或氛围感结果构建与原图的对比内容;其他类型或无效地址返回 nil。
|
||||||
|
func comparisonContent(for kind: TravelAlbumPreviewAssetKind) -> BeforeAfterComparisonContent? {
|
||||||
|
guard kind == .retouched || kind == .atmosphere,
|
||||||
|
let original = asset(for: .original),
|
||||||
|
let result = asset(for: kind)
|
||||||
|
else { return nil }
|
||||||
|
let content = BeforeAfterComparisonContent(
|
||||||
|
beforeURL: original.displayURL,
|
||||||
|
afterURL: result.displayURL
|
||||||
|
)
|
||||||
|
return content.isValid ? content : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
||||||
|
init(material: TravelAlbumMaterial) {
|
||||||
|
originalMaterialId = material.id
|
||||||
|
aiRetouchBatchId = material.aiRetouchBatchId
|
||||||
|
var mappedAssets = [
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "original-\(material.id)",
|
||||||
|
kind: .original,
|
||||||
|
fileURL: material.fileUrl,
|
||||||
|
coverURL: material.coverUrl,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
),
|
||||||
|
]
|
||||||
|
let refinedURL = material.aiRefinedURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !refinedURL.isEmpty {
|
||||||
|
mappedAssets.append(
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "retouched-\(material.id)",
|
||||||
|
kind: .retouched,
|
||||||
|
fileURL: refinedURL,
|
||||||
|
coverURL: refinedURL,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let atmosphereURL = material.aiAtmosphereURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !atmosphereURL.isEmpty {
|
||||||
|
mappedAssets.append(
|
||||||
|
TravelAlbumPreviewAsset(
|
||||||
|
id: "atmosphere-\(material.id)",
|
||||||
|
kind: .atmosphere,
|
||||||
|
fileURL: atmosphereURL,
|
||||||
|
coverURL: atmosphereURL,
|
||||||
|
fileName: material.fileName,
|
||||||
|
fileSize: material.fileSize
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assets = mappedAssets
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建包含关联图片的项目,主要供适配器和测试使用。
|
||||||
|
init(originalMaterialId: Int, aiRetouchBatchId: Int = 0, assets: [TravelAlbumPreviewAsset]) {
|
||||||
|
self.originalMaterialId = originalMaterialId
|
||||||
|
self.aiRetouchBatchId = aiRetouchBatchId
|
||||||
|
var seenKinds = Set<TravelAlbumPreviewAssetKind>()
|
||||||
|
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据是否已有 AI 结果生成首次修图或双模板覆盖重修工作流。
|
||||||
|
func aiRetouchWorkflow(
|
||||||
|
albumId: Int,
|
||||||
|
selectedKind: TravelAlbumPreviewAssetKind
|
||||||
|
) -> TravelAlbumAIRetouchWorkflow? {
|
||||||
|
guard hasVariants else {
|
||||||
|
return .initial(albumId: albumId, materialIds: [originalMaterialId])
|
||||||
|
}
|
||||||
|
guard selectedKind != .cover else { return nil }
|
||||||
|
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TravelAlbumAIRetouchTemplate {
|
||||||
|
/// 使用接口返回的模板示例前后图构建对比页内容。
|
||||||
|
var comparisonContent: BeforeAfterComparisonContent? {
|
||||||
|
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
|
||||||
|
return content.isValid ? content : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览分页节点,记录当前图片所属项目及类型。
|
||||||
|
struct TravelAlbumPreviewNode: Sendable, Hashable {
|
||||||
|
let projectIndex: Int
|
||||||
|
let kind: TravelAlbumPreviewAssetKind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页纯状态工具,负责构建节点和处理分组滑动边界。
|
||||||
|
enum TravelAlbumPreviewNavigator {
|
||||||
|
/// 根据滑动模式生成页面节点。
|
||||||
|
static func nodes(
|
||||||
|
projects: [TravelAlbumPreviewProject],
|
||||||
|
mode: TravelAlbumPreviewSwipeMode
|
||||||
|
) -> [TravelAlbumPreviewNode] {
|
||||||
|
projects.enumerated().flatMap { index, project in
|
||||||
|
switch mode {
|
||||||
|
case .projectsOnly:
|
||||||
|
[TravelAlbumPreviewNode(projectIndex: index, kind: .original)]
|
||||||
|
case .includeVariants:
|
||||||
|
project.orderedAssets.map { TravelAlbumPreviewNode(projectIndex: index, kind: $0.kind) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理反向滑动边界:从项目原图向右滑时直接进入上一项目原图。
|
||||||
|
static func backwardTargetIndex(
|
||||||
|
nodes: [TravelAlbumPreviewNode],
|
||||||
|
currentIndex: Int
|
||||||
|
) -> Int {
|
||||||
|
guard nodes.indices.contains(currentIndex), currentIndex > 0 else { return max(0, currentIndex) }
|
||||||
|
let current = nodes[currentIndex]
|
||||||
|
guard current.kind == .original, current.projectIndex > 0 else { return currentIndex - 1 }
|
||||||
|
return nodes.firstIndex {
|
||||||
|
$0.projectIndex == current.projectIndex - 1 && $0.kind == .original
|
||||||
|
} ?? currentIndex - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回删除当前项目后应展示的项目索引;优先保持原索引以显示下一张,末项则回退上一张。
|
||||||
|
static func projectIndexAfterDeletion(
|
||||||
|
deletedProjectIndex: Int,
|
||||||
|
remainingProjectCount: Int
|
||||||
|
) -> Int? {
|
||||||
|
guard remainingProjectCount > 0 else { return nil }
|
||||||
|
return min(max(0, deletedProjectIndex), remainingProjectCount - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览删除操作执行结果,统一表达成功、失败与暂不可用状态。
|
||||||
|
enum TravelAlbumPreviewActionResult: Sendable, Equatable {
|
||||||
|
case success(String?)
|
||||||
|
case failure(String)
|
||||||
|
case unavailable(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页删除操作协议,以原素材项目 ID 为目标。
|
||||||
|
protocol TravelAlbumPreviewActionHandling: AnyObject {
|
||||||
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览删除接口接入前的占位操作实现,不修改任何业务数据。
|
||||||
|
final class PlaceholderTravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
||||||
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
|
.unavailable("项目删除接口待接入")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// BeforeAfterComparisonViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 前后对比分隔线的共享运动参数与计算规则。
|
||||||
|
enum BeforeAfterComparisonMotion {
|
||||||
|
static let automaticCenter: CGFloat = 0.5
|
||||||
|
static let automaticAmplitude: CGFloat = 0.3
|
||||||
|
static let automaticSpeed: Double = 0.8
|
||||||
|
static let manualRange: ClosedRange<CGFloat> = 0.05 ... 0.95
|
||||||
|
|
||||||
|
/// 返回指定时间点的自动往返位置,运动范围固定为 20% 至 80%。
|
||||||
|
static func automaticFraction(elapsed: TimeInterval) -> CGFloat {
|
||||||
|
automaticCenter + CGFloat(sin(elapsed * automaticSpeed)) * automaticAmplitude
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将手动拖动位置限制在两侧 5% 的安全边界内。
|
||||||
|
static func clampedManualFraction(_ fraction: CGFloat) -> CGFloat {
|
||||||
|
min(manualRange.upperBound, max(manualRange.lowerBound, fraction))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算从当前位置恢复正向自动运动所需的正弦相位时间。
|
||||||
|
static func phaseTime(for fraction: CGFloat) -> TimeInterval {
|
||||||
|
let normalized = Double(
|
||||||
|
min(1, max(-1, (fraction - automaticCenter) / automaticAmplitude))
|
||||||
|
)
|
||||||
|
return asin(normalized) / automaticSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后对比图片在可用区域内完整展示时的等比布局计算。
|
||||||
|
enum BeforeAfterComparisonLayout {
|
||||||
|
static let fallbackAspectRatio: CGFloat = 3.0 / 4.0
|
||||||
|
|
||||||
|
/// 从图片尺寸提取有效宽高比,异常尺寸回退为 3:4。
|
||||||
|
static func aspectRatio(for imageSize: CGSize) -> CGFloat {
|
||||||
|
guard imageSize.width > 0, imageSize.height > 0 else { return fallbackAspectRatio }
|
||||||
|
let ratio = imageSize.width / imageSize.height
|
||||||
|
return ratio.isFinite && ratio > 0 ? ratio : fallbackAspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在最大宽高内返回不裁剪、不拉伸的最大显示尺寸。
|
||||||
|
static func fittedSize(aspectRatio: CGFloat, maximumSize: CGSize) -> CGSize {
|
||||||
|
guard maximumSize.width > 0, maximumSize.height > 0 else { return .zero }
|
||||||
|
let ratio = aspectRatio.isFinite && aspectRatio > 0 ? aspectRatio : fallbackAspectRatio
|
||||||
|
if maximumSize.width / maximumSize.height > ratio {
|
||||||
|
return CGSize(width: maximumSize.height * ratio, height: maximumSize.height)
|
||||||
|
}
|
||||||
|
return CGSize(width: maximumSize.width, height: maximumSize.width / ratio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前后图片对比页的只读状态,负责校验并解析两张远程图片地址。
|
||||||
|
final class BeforeAfterComparisonViewModel {
|
||||||
|
let content: BeforeAfterComparisonContent
|
||||||
|
|
||||||
|
/// 创建指定内容的前后对比页状态。
|
||||||
|
init(content: BeforeAfterComparisonContent) {
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原图远程地址。
|
||||||
|
var beforeImageURL: URL? {
|
||||||
|
URL(string: content.beforeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 效果图远程地址。
|
||||||
|
var afterImageURL: URL? {
|
||||||
|
URL(string: content.afterURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两张图片地址都可供页面加载时返回 true。
|
||||||
|
var isValid: Bool {
|
||||||
|
content.isValid && beforeImageURL != nil && afterImageURL != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图任务列表状态,负责筛选、游标分页、去重和静默刷新。
|
||||||
|
final class TravelAlbumAIJobListViewModel {
|
||||||
|
private(set) var items: [TravelAlbumAIJobSummary] = []
|
||||||
|
private(set) var selectedFilter: TravelAlbumAIJobFilter = .all
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var isLoadingMore = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private var nextCursor: String?
|
||||||
|
private var hasMore = false
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
var containsInProgressJobs: Bool { items.contains { $0.status.isInProgress } }
|
||||||
|
var canLoadMore: Bool { hasMore && !isLoadingMore }
|
||||||
|
|
||||||
|
/// 选择筛选项并重新加载第一页。
|
||||||
|
func selectFilter(_ filter: TravelAlbumAIJobFilter, api: any TravelAlbumServing) async {
|
||||||
|
guard selectedFilter != filter else { return }
|
||||||
|
selectedFilter = filter
|
||||||
|
items = []
|
||||||
|
notify()
|
||||||
|
await loadFirstPage(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次加载或下拉刷新第一页。
|
||||||
|
func loadFirstPage(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||||
|
guard !isLoading && !isRefreshing else { return }
|
||||||
|
if refreshing { isRefreshing = true } else if !silent { isLoading = true }
|
||||||
|
errorMessage = nil
|
||||||
|
notify()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载下一页。
|
||||||
|
func loadMore(api: any TravelAlbumServing) async {
|
||||||
|
guard canLoadMore, let cursor = nextCursor else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
notify()
|
||||||
|
defer { isLoadingMore = false; notify() }
|
||||||
|
await request(cursor: cursor, reset: false, api: api, allowCursorRecovery: true, silent: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(
|
||||||
|
cursor: String?,
|
||||||
|
reset: Bool,
|
||||||
|
api: any TravelAlbumServing,
|
||||||
|
allowCursorRecovery: Bool,
|
||||||
|
silent: Bool
|
||||||
|
) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.aiRetouchJobList(
|
||||||
|
statusGroup: selectedFilter,
|
||||||
|
limit: 20,
|
||||||
|
cursor: cursor
|
||||||
|
)
|
||||||
|
if reset {
|
||||||
|
items = response.items
|
||||||
|
} else {
|
||||||
|
var known = Set(items.map(\.id))
|
||||||
|
items += response.items.filter { known.insert($0.id).inserted }
|
||||||
|
}
|
||||||
|
nextCursor = response.nextCursor
|
||||||
|
hasMore = response.hasMore && response.nextCursor?.isEmpty == false
|
||||||
|
errorMessage = nil
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription
|
||||||
|
if allowCursorRecovery && message.localizedCaseInsensitiveContains("cursor") {
|
||||||
|
nextCursor = nil
|
||||||
|
hasMore = false
|
||||||
|
await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items.isEmpty { errorMessage = message.isEmpty ? "任务加载失败" : message }
|
||||||
|
if !silent { onShowMessage?(message.isEmpty ? "任务加载失败" : message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() { onStateChange?() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务详情状态,负责刷新、404 识别和终态判断。
|
||||||
|
final class TravelAlbumAIJobDetailViewModel {
|
||||||
|
private(set) var detail: TravelAlbumAIJobDetail?
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
private(set) var isNotFound = false
|
||||||
|
|
||||||
|
let batchId: Int
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onNotFound: (() -> Void)?
|
||||||
|
|
||||||
|
init(batchId: Int) { self.batchId = batchId }
|
||||||
|
|
||||||
|
var shouldPoll: Bool { detail?.status.isInProgress == true }
|
||||||
|
|
||||||
|
/// 拉取任务详情;静默刷新失败时保留现有内容。
|
||||||
|
func load(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async {
|
||||||
|
guard batchId > 0, !isLoading && !isRefreshing else {
|
||||||
|
if batchId <= 0 { markNotFound() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if refreshing { isRefreshing = true } else if detail == nil && !silent { isLoading = true }
|
||||||
|
errorMessage = nil
|
||||||
|
notify()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
detail = try await api.aiRetouchJobInfo(batchId: batchId)
|
||||||
|
isNotFound = false
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch APIError.httpStatus(let status, _) where status == 404 {
|
||||||
|
markNotFound()
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription.isEmpty ? "任务详情加载失败" : error.localizedDescription
|
||||||
|
if detail == nil { errorMessage = message }
|
||||||
|
if !silent { onShowMessage?(message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markNotFound() {
|
||||||
|
guard !isNotFound else { return }
|
||||||
|
isNotFound = true
|
||||||
|
onNotFound?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notify() { onStateChange?() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumAIRetouchTemplateViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// AI 修图模板选择状态,负责实时加载、按工作流单选、校验和任务提交。
|
||||||
|
final class TravelAlbumAIRetouchTemplateViewModel {
|
||||||
|
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var coverTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||||
|
private(set) var selectedRefinedTemplateId: Int?
|
||||||
|
private(set) var selectedAtmosphereTemplateId: Int?
|
||||||
|
private(set) var selectedCoverTemplateId: Int?
|
||||||
|
private(set) var remainingQuota: Int?
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isSubmitting = false
|
||||||
|
private(set) var loadErrorMessage: String?
|
||||||
|
|
||||||
|
let scenicId: Int
|
||||||
|
let workflow: TravelAlbumAIRetouchWorkflow
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)?
|
||||||
|
|
||||||
|
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
|
||||||
|
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
||||||
|
self.init(
|
||||||
|
scenicId: scenicId,
|
||||||
|
workflow: .initial(albumId: albumId, materialIds: materialIds)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用明确工作流创建模板选择状态。
|
||||||
|
init(scenicId: Int, workflow: TravelAlbumAIRetouchWorkflow) {
|
||||||
|
self.scenicId = scenicId
|
||||||
|
switch workflow {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
self.workflow = .initial(
|
||||||
|
albumId: albumId,
|
||||||
|
materialIds: Array(Set(materialIds)).sorted()
|
||||||
|
)
|
||||||
|
case .reretouch:
|
||||||
|
self.workflow = workflow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前工作流需要展示的模板分组。
|
||||||
|
var visibleCategories: [TravelAlbumAIRetouchTemplateCategory] {
|
||||||
|
workflow.visibleCategories
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前工作流选择的原始照片数量。
|
||||||
|
var selectedPhotoCount: Int {
|
||||||
|
switch workflow {
|
||||||
|
case .initial(_, let materialIds): materialIds.count
|
||||||
|
case .reretouch: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否展示首次修图的氛围感与封面生成说明。
|
||||||
|
var shouldShowInitialTips: Bool {
|
||||||
|
if case .initial = workflow { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次修图页按照当前选图数量展示氛围感与封面生成规则。
|
||||||
|
var initialTipsText: String {
|
||||||
|
if visibleCategories.contains(.cover) {
|
||||||
|
return "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
|
||||||
|
}
|
||||||
|
return "Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前分组是否为选填。
|
||||||
|
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||||
|
workflow.isOptional(category)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选择预计消耗的修图次数;封面模板免费,不计入额度。
|
||||||
|
var requiredQuota: Int {
|
||||||
|
switch workflow {
|
||||||
|
case .initial(_, let materialIds):
|
||||||
|
let outputCount = 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||||
|
return materialIds.count * outputCount
|
||||||
|
case .reretouch(_, _, let type):
|
||||||
|
switch type {
|
||||||
|
case .refined, .atmosphere:
|
||||||
|
return 1
|
||||||
|
case .all:
|
||||||
|
return (selectedRefinedTemplateId == nil ? 0 : 1)
|
||||||
|
+ (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前必选模板或业务参数缺失时用于底部提示的文案。
|
||||||
|
var validationMessage: String? {
|
||||||
|
guard !isLoading, loadErrorMessage == nil else { return nil }
|
||||||
|
guard workflow.isValid else {
|
||||||
|
if case .reretouch = workflow {
|
||||||
|
return "当前图片缺少修图批次,请刷新后重试"
|
||||||
|
}
|
||||||
|
return "请选择要修图的照片"
|
||||||
|
}
|
||||||
|
for category in visibleCategories where !isOptional(category) {
|
||||||
|
if templates(for: category).isEmpty || selectedTemplateId(for: category) == nil {
|
||||||
|
return unavailableMessage(for: category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if case .reretouch(_, _, .all) = workflow,
|
||||||
|
selectedRefinedTemplateId == nil,
|
||||||
|
selectedAtmosphereTemplateId == nil {
|
||||||
|
return "请至少选择一个修图模板"
|
||||||
|
}
|
||||||
|
guard let remainingQuota else {
|
||||||
|
return "剩余修图次数获取失败,请刷新后重试"
|
||||||
|
}
|
||||||
|
if requiredQuota > remainingQuota {
|
||||||
|
return "剩余修图次数不足,需要\(requiredQuota)次,当前剩余\(remainingQuota)次"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否满足提交条件。
|
||||||
|
var canSubmit: Bool {
|
||||||
|
!isLoading
|
||||||
|
&& !isSubmitting
|
||||||
|
&& loadErrorMessage == nil
|
||||||
|
&& workflow.isValid
|
||||||
|
&& validationMessage == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实时拉取当前景区模板并按工作流设置默认选择。
|
||||||
|
func loadTemplates(api: any TravelAlbumServing) async {
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
loadErrorMessage = "请先选择景区"
|
||||||
|
notifyStateChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
loadErrorMessage = nil
|
||||||
|
remainingQuota = nil
|
||||||
|
notifyStateChange()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
|
||||||
|
refinedTemplates = response.refinedTemplates
|
||||||
|
atmosphereTemplates = response.atmosphereTemplates
|
||||||
|
coverTemplates = response.coverTemplates
|
||||||
|
remainingQuota = response.remainingQuota
|
||||||
|
selectedRefinedTemplateId = visibleCategories.contains(.refined) && !isOptional(.refined)
|
||||||
|
? refinedTemplates.first?.id
|
||||||
|
: nil
|
||||||
|
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
||||||
|
? atmosphereTemplates.first?.id
|
||||||
|
: nil
|
||||||
|
selectedCoverTemplateId = visibleCategories.contains(.cover) ? coverTemplates.first?.id : nil
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
} catch {
|
||||||
|
refinedTemplates = []
|
||||||
|
atmosphereTemplates = []
|
||||||
|
coverTemplates = []
|
||||||
|
selectedRefinedTemplateId = nil
|
||||||
|
selectedAtmosphereTemplateId = nil
|
||||||
|
selectedCoverTemplateId = nil
|
||||||
|
remainingQuota = nil
|
||||||
|
isLoading = false
|
||||||
|
loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定分组的模板。
|
||||||
|
func templates(for category: TravelAlbumAIRetouchTemplateCategory) -> [TravelAlbumAIRetouchTemplate] {
|
||||||
|
switch category {
|
||||||
|
case .refined: refinedTemplates
|
||||||
|
case .atmosphere: atmosphereTemplates
|
||||||
|
case .cover: coverTemplates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定分组当前选中的模板 ID。
|
||||||
|
func selectedTemplateId(for category: TravelAlbumAIRetouchTemplateCategory) -> Int? {
|
||||||
|
switch category {
|
||||||
|
case .refined: selectedRefinedTemplateId
|
||||||
|
case .atmosphere: selectedAtmosphereTemplateId
|
||||||
|
case .cover: selectedCoverTemplateId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择模板;选填分组允许再次点击取消,必选分组保持单选。
|
||||||
|
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
||||||
|
guard visibleCategories.contains(category),
|
||||||
|
templates(for: category).contains(where: { $0.id == id })
|
||||||
|
else { return }
|
||||||
|
switch category {
|
||||||
|
case .refined:
|
||||||
|
selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id
|
||||||
|
case .atmosphere:
|
||||||
|
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
||||||
|
case .cover:
|
||||||
|
selectedCoverTemplateId = id
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按当前工作流提交首次修图或覆盖重修任务。
|
||||||
|
func submit(api: any TravelAlbumServing) async {
|
||||||
|
guard !isSubmitting else { return }
|
||||||
|
guard canSubmit else {
|
||||||
|
onShowMessage?(validationMessage ?? "当前无法提交AI修图")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isSubmitting = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let submission: TravelAlbumAIJobSubmission
|
||||||
|
switch workflow {
|
||||||
|
case .initial(let albumId, let materialIds):
|
||||||
|
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
||||||
|
onShowMessage?("请选择原图精修模板")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submission = try await api.submitAIRetouch(
|
||||||
|
TravelAlbumAIRetouchRequest(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
materialIds: materialIds,
|
||||||
|
refinedTemplateId: refinedTemplateId,
|
||||||
|
atmosphereTemplateId: selectedAtmosphereTemplateId,
|
||||||
|
coverTemplateId: selectedCoverTemplateId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
case .reretouch(let materialId, let batchId, let type):
|
||||||
|
submission = try await api.submitAIReretouch(
|
||||||
|
TravelAlbumAIReretouchRequest(
|
||||||
|
id: materialId,
|
||||||
|
aiRetouchBatchId: batchId,
|
||||||
|
type: type,
|
||||||
|
refinedTemplateId: type == .atmosphere ? nil : selectedRefinedTemplateId,
|
||||||
|
atmosphereTemplateId: type == .refined ? nil : selectedAtmosphereTemplateId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onSubmitted?(submission)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "AI修图任务提交失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unavailableMessage(for category: TravelAlbumAIRetouchTemplateCategory) -> String {
|
||||||
|
switch category {
|
||||||
|
case .refined: "暂无可用的原图精修模板"
|
||||||
|
case .atmosphere: "暂无可用的氛围感修图模板"
|
||||||
|
case .cover: "暂无可用的封面风格模板"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,17 +27,28 @@ final class TravelAlbumDetailViewModel {
|
|||||||
case .fileNameDesc: "文件名倒序"
|
case .fileNameDesc: "文件名倒序"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var compactTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .createdAsc: "时间 ↑"
|
||||||
|
case .createdDesc: "时间 ↓"
|
||||||
|
case .fileNameAsc: "名称 ↑"
|
||||||
|
case .fileNameDesc: "名称 ↓"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var album: TravelAlbum?
|
private(set) var album: TravelAlbum?
|
||||||
private(set) var materials: [TravelAlbumMaterial] = []
|
private(set) var materials: [TravelAlbumMaterial] = []
|
||||||
private(set) var allPhotoCount = 0
|
private(set) var allPhotoCount = 0
|
||||||
|
private(set) var purchasedPhotoCount = 0
|
||||||
private(set) var selectedTab: Tab = .all
|
private(set) var selectedTab: Tab = .all
|
||||||
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 isSelectionMode = false
|
private(set) var isSelectionMode = false
|
||||||
private(set) var selectedMaterialIds: Set<Int> = []
|
private(set) var selectedMaterialIds: Set<Int> = []
|
||||||
|
private var selectedMaterialPurchaseStates: [Int: Bool] = [:]
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
@@ -65,6 +76,7 @@ final class TravelAlbumDetailViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
await loadAlbumInfo(api: api)
|
await loadAlbumInfo(api: api)
|
||||||
await loadAllPhotoCount(api: api)
|
await loadAllPhotoCount(api: api)
|
||||||
|
await loadPurchasedPhotoCount(api: api)
|
||||||
await loadMaterials(reset: true, api: api)
|
await loadMaterials(reset: true, api: api)
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -102,12 +114,58 @@ final class TravelAlbumDetailViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拉取已购照片数量;失败时保留已有数量,不影响素材列表加载。
|
||||||
|
func loadPurchasedPhotoCount(api: any TravelAlbumServing) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.materialList(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
orderBy: sortOption.rawValue,
|
||||||
|
isPurchased: 1
|
||||||
|
)
|
||||||
|
purchasedPhotoCount = response.total
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要卡封面地址,依次回退相册封面、首张素材封面和首张素材原图。
|
||||||
|
var displayCoverURL: String {
|
||||||
|
TravelAlbumDisplayFormatter.albumCoverURL(album: album, materials: materials)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前 Tab 对应的原图项目总数,供全屏预览索引使用。
|
||||||
|
var currentPhotoCount: Int {
|
||||||
|
selectedTab == .all ? allPhotoCount : purchasedPhotoCount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前选中素材中的已购买数量。
|
||||||
|
var selectedPurchasedMaterialCount: Int {
|
||||||
|
selectedMaterialIds.count - selectedDeletableMaterialIds.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除确认弹窗文案;选中已购素材时明确说明保护规则与实际删除数量。
|
||||||
|
var deleteSelectedConfirmationMessage: String {
|
||||||
|
let selectedCount = selectedMaterialIds.count
|
||||||
|
let purchasedCount = selectedPurchasedMaterialCount
|
||||||
|
guard purchasedCount > 0 else {
|
||||||
|
return "确定删除选中的 \(selectedCount) 张素材吗?"
|
||||||
|
}
|
||||||
|
let deletableCount = selectedCount - purchasedCount
|
||||||
|
return "已选择 \(selectedCount) 张素材,其中 \(purchasedCount) 张已购买。"
|
||||||
|
+ "不会删除已购买的项目,只会删除 \(deletableCount) 张未购买的项目。"
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换素材 tab。
|
/// 切换素材 tab。
|
||||||
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
||||||
guard selectedTab != tab else { return }
|
guard selectedTab != tab else { return }
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
await loadMaterials(reset: true, api: api)
|
await loadMaterials(reset: true, api: api)
|
||||||
}
|
}
|
||||||
@@ -144,6 +202,8 @@ final class TravelAlbumDetailViewModel {
|
|||||||
canLoadMore = materials.count < response.total
|
canLoadMore = materials.count < response.total
|
||||||
if selectedTab == .all {
|
if selectedTab == .all {
|
||||||
allPhotoCount = response.total
|
allPhotoCount = response.total
|
||||||
|
} else {
|
||||||
|
purchasedPhotoCount = response.total
|
||||||
}
|
}
|
||||||
isLoadingMore = false
|
isLoadingMore = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
@@ -165,12 +225,25 @@ final class TravelAlbumDetailViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 定向刷新单个素材,并同步替换网格列表中的对应缓存。
|
||||||
|
func refreshMaterial(id: Int, api: any TravelAlbumServing) async throws -> TravelAlbumMaterial {
|
||||||
|
let material = try await api.materialInfo(
|
||||||
|
userEquityTravelId: albumId,
|
||||||
|
materialId: id
|
||||||
|
)
|
||||||
|
guard material.id == id else { throw APIError.invalidResponse }
|
||||||
|
if let index = materials.firstIndex(where: { $0.id == id }) {
|
||||||
|
materials[index] = material
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
return material
|
||||||
|
}
|
||||||
|
|
||||||
/// 切换选择模式。
|
/// 切换选择模式。
|
||||||
func toggleSelectionMode() {
|
func toggleSelectionMode() {
|
||||||
guard selectedTab == .all else { return }
|
|
||||||
isSelectionMode.toggle()
|
isSelectionMode.toggle()
|
||||||
if !isSelectionMode {
|
if !isSelectionMode {
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
@@ -178,32 +251,58 @@ final class TravelAlbumDetailViewModel {
|
|||||||
/// 切换素材选中状态。
|
/// 切换素材选中状态。
|
||||||
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||||
guard isSelectionMode else { return }
|
guard isSelectionMode else { return }
|
||||||
guard material.status == 1 else {
|
|
||||||
onShowMessage?("仅未购买素材可删除")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if selectedMaterialIds.contains(material.id) {
|
if selectedMaterialIds.contains(material.id) {
|
||||||
selectedMaterialIds.remove(material.id)
|
selectedMaterialIds.remove(material.id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: material.id)
|
||||||
} else {
|
} else {
|
||||||
selectedMaterialIds.insert(material.id)
|
selectedMaterialIds.insert(material.id)
|
||||||
|
selectedMaterialPurchaseStates[material.id] = material.isPurchased
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
||||||
|
func completeAIRetouchSubmission() {
|
||||||
|
isSelectionMode = false
|
||||||
|
clearMaterialSelection()
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 修图提交成功后重置选择状态,并刷新相册摘要、数量与当前素材列表。
|
||||||
|
func refreshAfterAIRetouchSubmission(api: any TravelAlbumServing) async {
|
||||||
|
completeAIRetouchSubmission()
|
||||||
|
await refreshAll(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
||||||
|
func removeMaterialAfterPreviewDeletion(id: Int) {
|
||||||
|
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
let material = materials.remove(at: index)
|
||||||
|
selectedMaterialIds.remove(id)
|
||||||
|
selectedMaterialPurchaseStates.removeValue(forKey: id)
|
||||||
|
allPhotoCount = max(0, allPhotoCount - 1)
|
||||||
|
if material.isPurchased {
|
||||||
|
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
||||||
}
|
}
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除已选素材。
|
/// 删除已选素材。
|
||||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||||
let ids = Array(selectedMaterialIds)
|
guard !selectedMaterialIds.isEmpty else {
|
||||||
guard !ids.isEmpty else {
|
|
||||||
onShowMessage?("请选择要删除的素材")
|
onShowMessage?("请选择要删除的素材")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let ids = selectedDeletableMaterialIds
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
onShowMessage?("选中的素材均已购买,未删除任何项目")
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
for id in ids {
|
try await api.batchDeleteMaterials(ids: ids)
|
||||||
try await api.deleteMaterial(id: id)
|
|
||||||
}
|
|
||||||
onShowMessage?("删除成功")
|
onShowMessage?("删除成功")
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
selectedMaterialIds = []
|
clearMaterialSelection()
|
||||||
await refreshAll(api: api)
|
await refreshAll(api: api)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
@@ -228,4 +327,18 @@ final class TravelAlbumDetailViewModel {
|
|||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func clearMaterialSelection() {
|
||||||
|
selectedMaterialIds = []
|
||||||
|
selectedMaterialPurchaseStates = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selectedDeletableMaterialIds: [Int] {
|
||||||
|
selectedMaterialIds
|
||||||
|
.filter { id in
|
||||||
|
guard selectedMaterialPurchaseStates[id] == false else { return false }
|
||||||
|
return materials.first(where: { $0.id == id })?.isPurchased != true
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ final class TravelAlbumEntryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 重新拉取相册列表。
|
/// 重新拉取相册列表。
|
||||||
func loadAlbums(api: any TravelAlbumServing) async {
|
func loadAlbums(
|
||||||
|
api: any TravelAlbumServing,
|
||||||
|
preservingContentOnFailure: Bool = false
|
||||||
|
) async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
defer {
|
defer {
|
||||||
@@ -67,12 +70,20 @@ final class TravelAlbumEntryViewModel {
|
|||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
albums = []
|
if !preservingContentOnFailure {
|
||||||
albumTotal = 0
|
albums = []
|
||||||
|
albumTotal = 0
|
||||||
|
}
|
||||||
onShowMessage?(error.localizedDescription)
|
onShowMessage?(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 用户下拉刷新相册列表;失败时保留当前内容,避免页面瞬间清空。
|
||||||
|
func refreshAlbums(api: any TravelAlbumServing) async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
await loadAlbums(api: api, preservingContentOnFailure: true)
|
||||||
|
}
|
||||||
|
|
||||||
/// 打开创建相册弹窗。
|
/// 打开创建相册弹窗。
|
||||||
func openCreateSheet(api: any TravelAlbumServing) async {
|
func openCreateSheet(api: any TravelAlbumServing) async {
|
||||||
guard !isCreating else { return }
|
guard !isCreating else { return }
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// TravelAlbumPreviewActionHandler.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 图片预览页业务操作实现,负责将底部操作转发到旅拍相册服务。
|
||||||
|
final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
||||||
|
private let api: any TravelAlbumServing
|
||||||
|
|
||||||
|
/// 使用旅拍相册服务创建操作处理器。
|
||||||
|
init(api: any TravelAlbumServing) {
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
||||||
|
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||||
|
do {
|
||||||
|
try await api.batchDeleteMaterials(ids: [originalMaterialId])
|
||||||
|
return .success("删除成功")
|
||||||
|
} catch is CancellationError {
|
||||||
|
return .failure("删除已取消")
|
||||||
|
} catch {
|
||||||
|
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return .failure(message.isEmpty ? "删除失败" : message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||