Compare commits
16
Commits
7b9e630838
...
1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca4bd5a20 | ||
|
|
eb57666033 | ||
|
|
2c4123214d | ||
|
|
8cd6c0aac8 | ||
|
|
1757ba5e36 | ||
|
|
c34c4e1923 | ||
|
|
b127495e52 | ||
|
|
76aa7bb4aa | ||
|
|
8e444f7434 | ||
|
|
83afcb6deb | ||
|
|
30bfe0313f | ||
|
|
0f3b26991e | ||
|
|
e190b5958b | ||
|
|
6e2899dbfd | ||
|
|
48b6b9be22 | ||
|
|
202b0f5e41 |
@@ -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` 支持以下统一语义:
|
||||
|
||||
> 对本次传入模板对应的每种输出独立处理:已有结果则在新任务成功后覆盖,没有结果则新增;未传模板的类型不处理。不要因为某一种关联图历史上从未生成,就拒绝整个重新修图请求。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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 重修模板多倍图资源,优化弱网和大图加载体验。
|
||||
@@ -436,7 +436,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1020101;
|
||||
CURRENT_PROJECT_VERSION = 1030101;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||
@@ -460,7 +460,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -502,7 +502,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1020101;
|
||||
CURRENT_PROJECT_VERSION = 1030101;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||
@@ -526,7 +526,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -741,7 +741,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1020101;
|
||||
CURRENT_PROJECT_VERSION = 1030101;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -761,7 +761,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
|
||||
Vendored
+8
@@ -4,6 +4,14 @@
|
||||
"filename" : "ai_retouch_template_placeholder.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "travel_album_before_after.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
+5
@@ -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 |
@@ -5,25 +5,32 @@ enum PushDestination: Sendable, Equatable {
|
||||
case paymentRecord
|
||||
case paymentDetails
|
||||
case messageCenter
|
||||
case aiRetouchTaskList
|
||||
case aiRetouchTaskDetail(batchId: Int)
|
||||
}
|
||||
|
||||
/// 极光/APNs payload 解析结果,仅提取业务消息类型用于点击路由。
|
||||
struct PushPayload: Sendable, Equatable {
|
||||
private let type: String
|
||||
private let aiRetouchBatchId: Int?
|
||||
|
||||
/// 从系统通知 userInfo 提取顶层或推送包装层中的业务消息类型。
|
||||
nonisolated init(userInfo: [AnyHashable: Any]) {
|
||||
type = Self.extractType(from: userInfo)
|
||||
aiRetouchBatchId = Self.extractBatchId(from: userInfo)
|
||||
}
|
||||
|
||||
/// 从可发送的字符串字典创建 payload,仅保留业务消息类型。
|
||||
nonisolated init(values: [String: String]) {
|
||||
type = Self.normalizedType(values["type"])
|
||||
aiRetouchBatchId = Int(values["ai_retouch_batch_id"] ?? "").flatMap { $0 > 0 ? $0 : nil }
|
||||
}
|
||||
|
||||
/// 可发送的规范化字段快照。
|
||||
nonisolated var normalizedValues: [String: String] {
|
||||
type.isEmpty ? [:] : ["type": type]
|
||||
var values = type.isEmpty ? [:] : ["type": type]
|
||||
if let aiRetouchBatchId { values["ai_retouch_batch_id"] = String(aiRetouchBatchId) }
|
||||
return values
|
||||
}
|
||||
|
||||
/// 仅根据后端业务消息类型解析目标页面。
|
||||
@@ -33,6 +40,11 @@ struct PushPayload: Sendable, Equatable {
|
||||
return .paymentRecord
|
||||
case "6":
|
||||
return .paymentDetails
|
||||
case "14":
|
||||
if let aiRetouchBatchId, aiRetouchBatchId > 0 {
|
||||
return .aiRetouchTaskDetail(batchId: aiRetouchBatchId)
|
||||
}
|
||||
return .aiRetouchTaskList
|
||||
default:
|
||||
return .messageCenter
|
||||
}
|
||||
@@ -80,6 +92,50 @@ struct PushPayload: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func extractBatchId(
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -30,6 +30,37 @@ enum MessageJSONValue: Decodable, Hashable, Sendable {
|
||||
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
|
||||
}
|
||||
|
||||
/// 是否为 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 {
|
||||
MessageItem(
|
||||
|
||||
@@ -163,6 +163,16 @@ final class MessageDetailViewModel {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
/// AI 修图任务通知在详情页展示任务入口。
|
||||
var showsAIRetouchTaskAction: Bool {
|
||||
message.isAIRetouchTaskNotification
|
||||
}
|
||||
|
||||
/// 当前消息携带的 AI 修图批次 ID;缺失时由页面降级进入任务列表。
|
||||
var aiRetouchBatchId: Int? {
|
||||
message.aiRetouchBatchId
|
||||
}
|
||||
|
||||
/// 删除当前消息。
|
||||
func delete(api: any MessageCenterServing) async {
|
||||
guard message.id > 0 else {
|
||||
|
||||
@@ -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,不把设计稿作为页面背景切图。
|
||||
@@ -86,10 +86,10 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
|
||||
| `scope` | 使用场景 |
|
||||
|---|---|
|
||||
| `batch` | 网格多选 AI 修图 |
|
||||
| `all_variants` | 预览页无 Tab,或当前是原图 Tab |
|
||||
| `refined_only` | 当前是精修后 Tab |
|
||||
| `atmosphere_only` | 当前是氛围感 Tab |
|
||||
| `batch` | 网格多选 AI 修图,或预览页尚无 AI 结果 Tab 时首次修图 |
|
||||
| `all_variants` | 预览页已有 AI 结果 Tab;当前为原图、精修后或氛围感均使用此范围 |
|
||||
| `refined_only` | 仅重修精修结果的接口能力,当前客户端无独立入口 |
|
||||
| `atmosphere_only` | 仅重修氛围感结果的接口能力,当前客户端无独立入口 |
|
||||
|
||||
`user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。
|
||||
|
||||
@@ -117,6 +117,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -135,6 +137,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -153,6 +157,8 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
"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
|
||||
}
|
||||
]
|
||||
@@ -178,6 +184,7 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave
|
||||
|
||||
- 不再让 App 传 `scenic_id`,后端从相册归属景区获取可用模板。
|
||||
- 由后端返回 `required` 和可见分组,避免多端各写一套“4 张显示封面”规则。
|
||||
- `preview_url` 用于模板卡片缩略图;`before_url` 与 `after_url` 必须是同尺寸、同构图的配对图片,供客户端滑动对比。
|
||||
- 客户端可默认选中必选分组的第一个可用模板,可选分组默认不选。
|
||||
- 此接口用于 UI 配置;提交时后端仍必须根据真实素材 ID 重新校验。
|
||||
- 同一响应返回当前用户的剩余可用额度和每类输出的额度单价,弹窗无需再发起第二个额度请求。
|
||||
@@ -219,7 +226,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.4.2 预览页原图 Tab
|
||||
#### 5.4.2 预览页已有 AI 结果 Tab
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -227,10 +234,6 @@ Content-Type: application/json
|
||||
"scope": "all_variants",
|
||||
"source_material_ids": ["2031"],
|
||||
"outputs": [
|
||||
{
|
||||
"type": "refined",
|
||||
"template_id": "tpl_refined_15"
|
||||
},
|
||||
{
|
||||
"type": "atmosphere",
|
||||
"template_id": "tpl_atmosphere_09"
|
||||
@@ -239,7 +242,7 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
如用户未选氛围感模板,`outputs` 中不传 `atmosphere`。缺失表示“本次不处理”,不表示删除现有氛围感图。
|
||||
已有精修或氛围感结果时,无论当前位于原图、精修后还是氛围感 Tab,精修和氛围感模板均为选填,但至少选择一种。`outputs` 只传本次选中的结果类型;缺失表示“本次不处理”,不表示删除或覆盖对应的已有结果。
|
||||
|
||||
#### 5.4.3 只重新精修
|
||||
|
||||
@@ -278,7 +281,7 @@ Content-Type: application/json
|
||||
| `scope` | 原图数 | 精修 | 氛围感 | 封面 |
|
||||
|---|---:|---|---|---|
|
||||
| `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 |
|
||||
| `all_variants` | 必须为 1 | 必选 | 可选 | 禁止 |
|
||||
| `all_variants` | 必须为 1 | 可选(与氛围感至少一项) | 可选(与精修至少一项) | 禁止 |
|
||||
| `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 |
|
||||
| `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 |
|
||||
|
||||
|
||||
@@ -114,9 +114,9 @@ Cell 左上角展示稳定的修图状态:
|
||||
| 当前情况 | 模板要求 | 生成与覆盖规则 |
|
||||
|---|---|---|
|
||||
| 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 |
|
||||
| 已显示 Tab,当前为“原图” | 精修必选,氛围感可选 | 重新生成精修图;如选氛围感则也重新生成。新结果成功后原子替换对应旧结果 |
|
||||
| 已显示 Tab,当前为“精修后” | 精修必选 | 只重新生成精修图,成功后覆盖旧精修图,不影响氛围感图 |
|
||||
| 已显示 Tab,当前为“氛围感” | 氛围感必选 | 只重新生成氛围感图,成功后覆盖旧氛围感图,不影响精修图 |
|
||||
| 已显示 Tab,当前为“原图” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||
| 已显示 Tab,当前为“精修后” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||
| 已显示 Tab,当前为“氛围感” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 |
|
||||
|
||||
重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。
|
||||
|
||||
|
||||
@@ -51,10 +51,20 @@ protocol TravelAlbumServing {
|
||||
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||
|
||||
/// 提交相册素材 AI 修图任务。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
|
||||
|
||||
/// 提交单张素材重新修图任务。
|
||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws
|
||||
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
|
||||
@@ -197,17 +207,42 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交相册素材 AI 修图任务;服务端 data 内容无需客户端消费。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
|
||||
/// 提交相册素材 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)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交重新修图任务;服务端返回的批次与额度信息当前无需消费。
|
||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "\(basePath)/ai-reretouch", body: request)
|
||||
/// 拉取指定 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))]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -224,7 +224,7 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
|
||||
/// 相册素材网格角标类别,用于稳定映射独立业务状态和语义颜色。
|
||||
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
||||
case purchased
|
||||
case pending
|
||||
@@ -241,14 +241,15 @@ struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
|
||||
}
|
||||
|
||||
extension TravelAlbumMaterial {
|
||||
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
|
||||
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||
if aiRetouchStatus == 0 {
|
||||
return isPurchased
|
||||
/// 生成购买状态角标;购买状态与修图状态互不覆盖。
|
||||
var purchaseBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||
isPurchased
|
||||
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||
: nil
|
||||
}
|
||||
|
||||
/// 生成 AI 修图状态角标;未进入修图流程时返回 nil。
|
||||
var aiRetouchBadgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch aiRetouchStatus {
|
||||
case 1:
|
||||
@@ -386,11 +387,17 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。
|
||||
/// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。
|
||||
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
|
||||
if case .initial = self, category == .atmosphere { return true }
|
||||
switch self {
|
||||
case .initial:
|
||||
return category == .atmosphere
|
||||
case .reretouch(_, _, .all):
|
||||
return category == .refined || category == .atmosphere
|
||||
case .reretouch:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 工作流目标是否满足接口的最小参数要求。
|
||||
var isValid: Bool {
|
||||
@@ -403,23 +410,45 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
|
||||
/// 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) {
|
||||
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)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
|
||||
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 切换。
|
||||
@@ -85,6 +111,19 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||
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
|
||||
@@ -136,7 +175,7 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||
self.assets = assets.filter { seenKinds.insert($0.kind).inserted }
|
||||
}
|
||||
|
||||
/// 根据当前 Tab 生成首次修图或覆盖重修工作流。
|
||||
/// 根据是否已有 AI 结果生成首次修图或双模板覆盖重修工作流。
|
||||
func aiRetouchWorkflow(
|
||||
albumId: Int,
|
||||
selectedKind: TravelAlbumPreviewAssetKind
|
||||
@@ -144,16 +183,16 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||
guard hasVariants else {
|
||||
return .initial(albumId: albumId, materialIds: [originalMaterialId])
|
||||
}
|
||||
switch selectedKind {
|
||||
case .original:
|
||||
guard selectedKind != .cover else { return nil }
|
||||
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all)
|
||||
case .retouched:
|
||||
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined)
|
||||
case .atmosphere:
|
||||
return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere)
|
||||
case .cover:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIRetouchTemplate {
|
||||
/// 使用接口返回的模板示例前后图构建对比页内容。
|
||||
var comparisonContent: BeforeAfterComparisonContent? {
|
||||
let content = BeforeAfterComparisonContent(beforeURL: beforeURL, afterURL: afterURL)
|
||||
return content.isValid ? content : nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?() }
|
||||
}
|
||||
+22
-10
@@ -23,7 +23,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onSubmitted: (() -> Void)?
|
||||
var onSubmitted: ((TravelAlbumAIJobSubmission) -> Void)?
|
||||
|
||||
/// 创建首次 AI 修图模板状态;素材 ID 会排序并去重,确保提交稳定。
|
||||
convenience init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
||||
@@ -66,9 +66,12 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
return false
|
||||
}
|
||||
|
||||
/// 首次修图页固定展示的氛围感与封面生成规则说明。
|
||||
/// 首次修图页按照当前选图数量展示氛围感与封面生成规则。
|
||||
var initialTipsText: String {
|
||||
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。"
|
||||
if visibleCategories.contains(.cover) {
|
||||
return "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
|
||||
}
|
||||
return "Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
|
||||
}
|
||||
|
||||
/// 当前分组是否为选填。
|
||||
@@ -87,7 +90,8 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
case .refined, .atmosphere:
|
||||
return 1
|
||||
case .all:
|
||||
return 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||
return (selectedRefinedTemplateId == nil ? 0 : 1)
|
||||
+ (selectedAtmosphereTemplateId == nil ? 0 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +110,11 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
return unavailableMessage(for: category)
|
||||
}
|
||||
}
|
||||
if case .reretouch(_, _, .all) = workflow,
|
||||
selectedRefinedTemplateId == nil,
|
||||
selectedAtmosphereTemplateId == nil {
|
||||
return "请至少选择一个修图模板"
|
||||
}
|
||||
guard let remainingQuota else {
|
||||
return "剩余修图次数获取失败,请刷新后重试"
|
||||
}
|
||||
@@ -143,7 +152,9 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
atmosphereTemplates = response.atmosphereTemplates
|
||||
coverTemplates = response.coverTemplates
|
||||
remainingQuota = response.remainingQuota
|
||||
selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil
|
||||
selectedRefinedTemplateId = visibleCategories.contains(.refined) && !isOptional(.refined)
|
||||
? refinedTemplates.first?.id
|
||||
: nil
|
||||
selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere)
|
||||
? atmosphereTemplates.first?.id
|
||||
: nil
|
||||
@@ -185,14 +196,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。
|
||||
/// 选择模板;选填分组允许再次点击取消,必选分组保持单选。
|
||||
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 = id
|
||||
selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id
|
||||
case .atmosphere:
|
||||
selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id
|
||||
case .cover:
|
||||
@@ -217,13 +228,14 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
}
|
||||
|
||||
do {
|
||||
let submission: TravelAlbumAIJobSubmission
|
||||
switch workflow {
|
||||
case .initial(let albumId, let materialIds):
|
||||
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
||||
onShowMessage?("请选择原图精修模板")
|
||||
return
|
||||
}
|
||||
try await api.submitAIRetouch(
|
||||
submission = try await api.submitAIRetouch(
|
||||
TravelAlbumAIRetouchRequest(
|
||||
userEquityTravelId: albumId,
|
||||
materialIds: materialIds,
|
||||
@@ -233,7 +245,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
)
|
||||
)
|
||||
case .reretouch(let materialId, let batchId, let type):
|
||||
try await api.submitAIReretouch(
|
||||
submission = try await api.submitAIReretouch(
|
||||
TravelAlbumAIReretouchRequest(
|
||||
id: materialId,
|
||||
aiRetouchBatchId: batchId,
|
||||
@@ -243,7 +255,7 @@ final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
)
|
||||
)
|
||||
}
|
||||
onSubmitted?()
|
||||
onSubmitted?(submission)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
|
||||
@@ -48,6 +48,7 @@ final class TravelAlbumDetailViewModel {
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isSelectionMode = false
|
||||
private(set) var selectedMaterialIds: Set<Int> = []
|
||||
private var selectedMaterialPurchaseStates: [Int: Bool] = [:]
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
@@ -142,12 +143,29 @@ final class TravelAlbumDetailViewModel {
|
||||
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。
|
||||
func selectTab(_ tab: Tab, api: any TravelAlbumServing) async {
|
||||
guard selectedTab != tab else { return }
|
||||
selectedTab = tab
|
||||
isSelectionMode = false
|
||||
selectedMaterialIds = []
|
||||
clearMaterialSelection()
|
||||
notifyStateChange()
|
||||
await loadMaterials(reset: true, api: api)
|
||||
}
|
||||
@@ -223,10 +241,9 @@ final class TravelAlbumDetailViewModel {
|
||||
|
||||
/// 切换选择模式。
|
||||
func toggleSelectionMode() {
|
||||
guard selectedTab == .all else { return }
|
||||
isSelectionMode.toggle()
|
||||
if !isSelectionMode {
|
||||
selectedMaterialIds = []
|
||||
clearMaterialSelection()
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
@@ -234,14 +251,12 @@ final class TravelAlbumDetailViewModel {
|
||||
/// 切换素材选中状态。
|
||||
func toggleMaterialSelection(_ material: TravelAlbumMaterial) {
|
||||
guard isSelectionMode else { return }
|
||||
guard material.status == 1 else {
|
||||
onShowMessage?("仅未购买素材可删除")
|
||||
return
|
||||
}
|
||||
if selectedMaterialIds.contains(material.id) {
|
||||
selectedMaterialIds.remove(material.id)
|
||||
selectedMaterialPurchaseStates.removeValue(forKey: material.id)
|
||||
} else {
|
||||
selectedMaterialIds.insert(material.id)
|
||||
selectedMaterialPurchaseStates[material.id] = material.isPurchased
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
@@ -249,15 +264,22 @@ final class TravelAlbumDetailViewModel {
|
||||
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
||||
func completeAIRetouchSubmission() {
|
||||
isSelectionMode = false
|
||||
selectedMaterialIds = []
|
||||
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)
|
||||
@@ -267,16 +289,20 @@ final class TravelAlbumDetailViewModel {
|
||||
|
||||
/// 删除已选素材。
|
||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||
let ids = selectedMaterialIds.sorted()
|
||||
guard !ids.isEmpty else {
|
||||
guard !selectedMaterialIds.isEmpty else {
|
||||
onShowMessage?("请选择要删除的素材")
|
||||
return
|
||||
}
|
||||
let ids = selectedDeletableMaterialIds
|
||||
guard !ids.isEmpty else {
|
||||
onShowMessage?("选中的素材均已购买,未删除任何项目")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await api.batchDeleteMaterials(ids: ids)
|
||||
onShowMessage?("删除成功")
|
||||
isSelectionMode = false
|
||||
selectedMaterialIds = []
|
||||
clearMaterialSelection()
|
||||
await refreshAll(api: api)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
@@ -301,4 +327,18 @@ final class TravelAlbumDetailViewModel {
|
||||
private func notifyStateChange() {
|
||||
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
|
||||
notifyStateChange()
|
||||
defer {
|
||||
@@ -67,12 +70,20 @@ final class TravelAlbumEntryViewModel {
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
if !preservingContentOnFailure {
|
||||
albums = []
|
||||
albumTotal = 0
|
||||
}
|
||||
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 {
|
||||
guard !isCreating else { return }
|
||||
|
||||
@@ -81,6 +81,10 @@ final class MainTabBarController: UITabBarController {
|
||||
controller = PaymentCollectionDetailsViewController()
|
||||
case .messageCenter:
|
||||
controller = MessageCenterViewController()
|
||||
case .aiRetouchTaskList:
|
||||
controller = TravelAlbumAIJobListViewController()
|
||||
case .aiRetouchTaskDetail(let batchId):
|
||||
controller = TravelAlbumAIJobDetailViewController(batchId: batchId)
|
||||
}
|
||||
|
||||
if let controller {
|
||||
|
||||
@@ -12,6 +12,7 @@ final class MessageDetailViewController: BaseViewController {
|
||||
|
||||
private let viewModel: MessageDetailViewModel
|
||||
private let api: any MessageCenterServing
|
||||
private let travelAlbumAPI: any TravelAlbumServing
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentView = UIView()
|
||||
@@ -20,13 +21,19 @@ final class MessageDetailViewController: BaseViewController {
|
||||
private let timeContainer = UIView()
|
||||
private let timeLabel = UILabel()
|
||||
private let bodyLabel = UILabel()
|
||||
private let taskDetailButton = UIButton(type: .system)
|
||||
private let bottomBar = UIView()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
|
||||
/// 初始化消息详情页。
|
||||
init(message: MessageItem, api: (any MessageCenterServing)? = nil) {
|
||||
init(
|
||||
message: MessageItem,
|
||||
api: (any MessageCenterServing)? = nil,
|
||||
travelAlbumAPI: (any TravelAlbumServing)? = nil
|
||||
) {
|
||||
viewModel = MessageDetailViewModel(message: message)
|
||||
self.api = api ?? NetworkServices.shared.messageCenterAPI
|
||||
self.travelAlbumAPI = travelAlbumAPI ?? NetworkServices.shared.travelAlbumAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@@ -67,6 +74,15 @@ final class MessageDetailViewController: BaseViewController {
|
||||
bodyLabel.numberOfLines = 0
|
||||
bodyLabel.textAlignment = .left
|
||||
|
||||
taskDetailButton.setTitle("查看任务详情", for: .normal)
|
||||
taskDetailButton.setTitleColor(.white, for: .normal)
|
||||
taskDetailButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
taskDetailButton.backgroundColor = AppColor.primary
|
||||
taskDetailButton.layer.cornerRadius = 10
|
||||
taskDetailButton.clipsToBounds = true
|
||||
taskDetailButton.isHidden = !viewModel.showsAIRetouchTaskAction
|
||||
taskDetailButton.accessibilityLabel = "查看AI修图任务详情"
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
deleteButton.setTitle("删除并返回", for: .normal)
|
||||
deleteButton.setTitleColor(.white, for: .normal)
|
||||
@@ -77,7 +93,7 @@ final class MessageDetailViewController: BaseViewController {
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentView)
|
||||
[typeImageView, titleLabel, timeContainer, bodyLabel].forEach(contentView.addSubview)
|
||||
[typeImageView, titleLabel, timeContainer, bodyLabel, taskDetailButton].forEach(contentView.addSubview)
|
||||
timeContainer.addSubview(timeLabel)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(deleteButton)
|
||||
@@ -121,12 +137,23 @@ final class MessageDetailViewController: BaseViewController {
|
||||
bodyLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(timeContainer.snp.bottom).offset(24)
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
if !viewModel.showsAIRetouchTaskAction {
|
||||
make.bottom.lessThanOrEqualToSuperview().inset(24)
|
||||
}
|
||||
}
|
||||
if viewModel.showsAIRetouchTaskAction {
|
||||
taskDetailButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(bodyLabel.snp.bottom).offset(24)
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
make.height.equalTo(52)
|
||||
make.bottom.lessThanOrEqualToSuperview().inset(24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||
taskDetailButton.addTarget(self, action: #selector(taskDetailTapped), for: .touchUpInside)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.applyState()
|
||||
@@ -145,6 +172,16 @@ final class MessageDetailViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func taskDetailTapped() {
|
||||
let target: UIViewController
|
||||
if let batchId = viewModel.aiRetouchBatchId {
|
||||
target = TravelAlbumAIJobDetailViewController(batchId: batchId, api: travelAlbumAPI)
|
||||
} else {
|
||||
target = TravelAlbumAIJobListViewController(api: travelAlbumAPI)
|
||||
}
|
||||
navigationController?.pushViewController(target, animated: true)
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
let alert = UIAlertController(title: "删除消息", message: "确定要删除这条消息吗?", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
//
|
||||
// BeforeAfterComparisonViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 暗色全屏前后图片对比页,通过可拖拽分隔线查看原图与效果图。
|
||||
final class BeforeAfterComparisonViewController: UIViewController {
|
||||
private let viewModel: BeforeAfterComparisonViewModel
|
||||
private let backButton = UIButton(type: .system)
|
||||
private let comparisonContainer = UIView()
|
||||
private let afterImageView = UIImageView()
|
||||
private let beforeImageView = UIImageView()
|
||||
private let beforeRevealMask = CAShapeLayer()
|
||||
private let dividerLine = UIView()
|
||||
private let dividerHandle = BeforeAfterComparisonHandleView()
|
||||
private let beforeLabel = BeforeAfterComparisonLabel()
|
||||
private let afterLabel = BeforeAfterComparisonLabel()
|
||||
private let statusContainer = UIView()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||
private let statusLabel = UILabel()
|
||||
private let retryButton = UIButton(type: .system)
|
||||
|
||||
private var dividerFraction: CGFloat = 0.5
|
||||
private var loadGeneration = 0
|
||||
private var beforeLoaded = false
|
||||
private var afterLoaded = false
|
||||
private var isReady = false
|
||||
private var displayLink: CADisplayLink?
|
||||
private var animationStartTime: CFTimeInterval?
|
||||
private var isDraggingDivider = false
|
||||
private var imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
|
||||
private var beforeImageSize: CGSize?
|
||||
private var afterImageSize: CGSize?
|
||||
|
||||
/// 创建指定前后图片内容的全屏对比页。
|
||||
init(viewModel: BeforeAfterComparisonViewModel) {
|
||||
self.viewModel = viewModel
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
bindActions()
|
||||
loadImages()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
layoutComparisonContainer()
|
||||
layoutDivider()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
startAutomaticMotion(from: dividerFraction)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
stopAutomaticMotion()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
displayLink?.invalidate()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0x05070D)
|
||||
view.accessibilityIdentifier = "travelAlbum.beforeAfterComparison"
|
||||
|
||||
var backConfiguration = UIButton.Configuration.filled()
|
||||
backConfiguration.image = UIImage(systemName: "chevron.left")
|
||||
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
|
||||
pointSize: 19,
|
||||
weight: .semibold
|
||||
)
|
||||
backConfiguration.baseForegroundColor = .white
|
||||
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||
backConfiguration.background.cornerRadius = 24
|
||||
backConfiguration.contentInsets = .zero
|
||||
backButton.configuration = backConfiguration
|
||||
backButton.accessibilityLabel = "返回"
|
||||
backButton.accessibilityIdentifier = "travelAlbum.beforeAfterBackButton"
|
||||
|
||||
comparisonContainer.backgroundColor = UIColor(hex: 0x111827)
|
||||
comparisonContainer.layer.cornerRadius = 0
|
||||
comparisonContainer.layer.borderColor = UIColor(hex: 0x263244).cgColor
|
||||
comparisonContainer.layer.borderWidth = 1
|
||||
comparisonContainer.clipsToBounds = true
|
||||
comparisonContainer.accessibilityIdentifier = "travelAlbum.beforeAfterImageContainer"
|
||||
|
||||
[afterImageView, beforeImageView].forEach { imageView in
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.clipsToBounds = true
|
||||
imageView.backgroundColor = UIColor(hex: 0x111827)
|
||||
imageView.isHidden = true
|
||||
}
|
||||
afterImageView.accessibilityLabel = viewModel.content.afterLabel
|
||||
beforeImageView.accessibilityLabel = viewModel.content.beforeLabel
|
||||
beforeImageView.layer.mask = beforeRevealMask
|
||||
|
||||
dividerLine.backgroundColor = UIColor.white.withAlphaComponent(0.72)
|
||||
dividerLine.layer.shadowColor = UIColor.black.cgColor
|
||||
dividerLine.layer.shadowOpacity = 0.35
|
||||
dividerLine.layer.shadowRadius = 2
|
||||
dividerLine.layer.shadowOffset = .zero
|
||||
dividerLine.isHidden = true
|
||||
dividerLine.isUserInteractionEnabled = false
|
||||
dividerHandle.isHidden = true
|
||||
dividerHandle.accessibilityIdentifier = "travelAlbum.beforeAfterDivider"
|
||||
|
||||
beforeLabel.text = viewModel.content.beforeLabel
|
||||
beforeLabel.accessibilityIdentifier = "travelAlbum.beforeLabel"
|
||||
afterLabel.text = viewModel.content.afterLabel
|
||||
afterLabel.accessibilityIdentifier = "travelAlbum.afterLabel"
|
||||
beforeLabel.isHidden = true
|
||||
afterLabel.isHidden = true
|
||||
|
||||
statusContainer.backgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.86)
|
||||
activityIndicator.color = .white
|
||||
activityIndicator.hidesWhenStopped = true
|
||||
statusLabel.textColor = UIColor.white.withAlphaComponent(0.78)
|
||||
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.numberOfLines = 0
|
||||
statusLabel.text = "正在加载对比图片"
|
||||
statusLabel.accessibilityIdentifier = "travelAlbum.beforeAfterStatusLabel"
|
||||
var retryConfiguration = UIButton.Configuration.filled()
|
||||
retryConfiguration.title = "重试"
|
||||
retryConfiguration.baseForegroundColor = .white
|
||||
retryConfiguration.baseBackgroundColor = UIColor(hex: 0x1677FF)
|
||||
retryConfiguration.background.cornerRadius = 12
|
||||
retryButton.configuration = retryConfiguration
|
||||
retryButton.accessibilityIdentifier = "travelAlbum.beforeAfterRetryButton"
|
||||
retryButton.isHidden = true
|
||||
|
||||
view.addSubview(backButton)
|
||||
view.addSubview(comparisonContainer)
|
||||
comparisonContainer.addSubview(afterImageView)
|
||||
comparisonContainer.addSubview(beforeImageView)
|
||||
comparisonContainer.addSubview(dividerLine)
|
||||
comparisonContainer.addSubview(dividerHandle)
|
||||
comparisonContainer.addSubview(beforeLabel)
|
||||
comparisonContainer.addSubview(afterLabel)
|
||||
comparisonContainer.addSubview(statusContainer)
|
||||
statusContainer.addSubview(activityIndicator)
|
||||
statusContainer.addSubview(statusLabel)
|
||||
statusContainer.addSubview(retryButton)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.size.equalTo(48)
|
||||
}
|
||||
afterImageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
beforeImageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
beforeLabel.snp.makeConstraints { make in
|
||||
make.leading.bottom.equalToSuperview().inset(10)
|
||||
make.height.equalTo(26)
|
||||
}
|
||||
afterLabel.snp.makeConstraints { make in
|
||||
make.trailing.bottom.equalToSuperview().inset(10)
|
||||
make.height.equalTo(26)
|
||||
}
|
||||
statusContainer.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
activityIndicator.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-28)
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(activityIndicator.snp.bottom).offset(14)
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
retryButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusLabel.snp.bottom).offset(14)
|
||||
make.centerX.equalToSuperview()
|
||||
make.width.equalTo(96)
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
}
|
||||
|
||||
private func bindActions() {
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||
let scrubberGesture = UILongPressGestureRecognizer(target: self, action: #selector(dividerDragged(_:)))
|
||||
scrubberGesture.minimumPressDuration = 0
|
||||
scrubberGesture.allowableMovement = .greatestFiniteMagnitude
|
||||
comparisonContainer.addGestureRecognizer(scrubberGesture)
|
||||
dividerHandle.onIncrement = { [weak self] in self?.adjustDivider(by: 0.1) }
|
||||
dividerHandle.onDecrement = { [weak self] in self?.adjustDivider(by: -0.1) }
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(reduceMotionStatusChanged),
|
||||
name: UIAccessibility.reduceMotionStatusDidChangeNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func loadImages() {
|
||||
loadGeneration += 1
|
||||
let generation = loadGeneration
|
||||
beforeImageView.kf.cancelDownloadTask()
|
||||
afterImageView.kf.cancelDownloadTask()
|
||||
beforeImageView.image = nil
|
||||
afterImageView.image = nil
|
||||
beforeLoaded = false
|
||||
afterLoaded = false
|
||||
beforeImageSize = nil
|
||||
afterImageSize = nil
|
||||
imageAspectRatio = BeforeAfterComparisonLayout.fallbackAspectRatio
|
||||
isReady = false
|
||||
dividerFraction = BeforeAfterComparisonMotion.automaticCenter
|
||||
stopAutomaticMotion()
|
||||
setComparisonVisible(false)
|
||||
statusContainer.isHidden = false
|
||||
statusLabel.text = "正在加载对比图片"
|
||||
retryButton.isHidden = true
|
||||
activityIndicator.startAnimating()
|
||||
|
||||
guard viewModel.isValid,
|
||||
let beforeURL = viewModel.beforeImageURL,
|
||||
let afterURL = viewModel.afterImageURL
|
||||
else {
|
||||
showLoadFailure()
|
||||
return
|
||||
}
|
||||
|
||||
beforeImageView.kf.setImage(with: beforeURL) { [weak self] result in
|
||||
self?.handleImageResult(result, isBefore: true, generation: generation)
|
||||
}
|
||||
afterImageView.kf.setImage(with: afterURL) { [weak self] result in
|
||||
self?.handleImageResult(result, isBefore: false, generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImageResult(
|
||||
_ result: Result<RetrieveImageResult, KingfisherError>,
|
||||
isBefore: Bool,
|
||||
generation: Int
|
||||
) {
|
||||
guard generation == loadGeneration else { return }
|
||||
switch result {
|
||||
case let .success(value):
|
||||
if isBefore {
|
||||
beforeLoaded = true
|
||||
beforeImageSize = value.image.size
|
||||
} else {
|
||||
afterLoaded = true
|
||||
afterImageSize = value.image.size
|
||||
}
|
||||
guard beforeLoaded && afterLoaded else { return }
|
||||
let resolvedSize = afterImageSize ?? beforeImageSize ?? .zero
|
||||
imageAspectRatio = BeforeAfterComparisonLayout.aspectRatio(for: resolvedSize)
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
isReady = true
|
||||
activityIndicator.stopAnimating()
|
||||
statusContainer.isHidden = true
|
||||
setComparisonVisible(true)
|
||||
layoutDivider()
|
||||
startAutomaticMotion(from: dividerFraction)
|
||||
UIAccessibility.post(notification: .announcement, argument: "对比图片已加载")
|
||||
case .failure:
|
||||
showLoadFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private func showLoadFailure() {
|
||||
isReady = false
|
||||
activityIndicator.stopAnimating()
|
||||
statusContainer.isHidden = false
|
||||
statusLabel.text = "图片加载失败,请重试"
|
||||
retryButton.isHidden = false
|
||||
setComparisonVisible(false)
|
||||
UIAccessibility.post(notification: .announcement, argument: statusLabel.text)
|
||||
}
|
||||
|
||||
private func setComparisonVisible(_ visible: Bool) {
|
||||
afterImageView.isHidden = !visible
|
||||
beforeImageView.isHidden = !visible
|
||||
dividerLine.isHidden = !visible
|
||||
dividerHandle.isHidden = !visible
|
||||
beforeLabel.isHidden = !visible
|
||||
afterLabel.isHidden = !visible
|
||||
}
|
||||
|
||||
private func layoutComparisonContainer() {
|
||||
let safeFrame = view.bounds.inset(by: view.safeAreaInsets)
|
||||
let topBoundary = max(safeFrame.minY, backButton.frame.maxY + 24)
|
||||
let bottomBoundary = safeFrame.maxY - 24
|
||||
let center = CGPoint(x: view.bounds.midX, y: safeFrame.midY)
|
||||
let verticalRadius = max(
|
||||
1,
|
||||
min(center.y - topBoundary, bottomBoundary - center.y)
|
||||
)
|
||||
let maximumSize = CGSize(
|
||||
width: max(1, view.bounds.width),
|
||||
height: verticalRadius * 2
|
||||
)
|
||||
let fittedSize = BeforeAfterComparisonLayout.fittedSize(
|
||||
aspectRatio: imageAspectRatio,
|
||||
maximumSize: maximumSize
|
||||
)
|
||||
comparisonContainer.frame = CGRect(
|
||||
x: center.x - fittedSize.width / 2,
|
||||
y: center.y - fittedSize.height / 2,
|
||||
width: fittedSize.width,
|
||||
height: fittedSize.height
|
||||
)
|
||||
comparisonContainer.layoutIfNeeded()
|
||||
}
|
||||
|
||||
private func layoutDivider() {
|
||||
guard comparisonContainer.bounds.width > 0 else { return }
|
||||
let bounds = comparisonContainer.bounds
|
||||
let x = bounds.width * dividerFraction
|
||||
beforeRevealMask.frame = beforeImageView.bounds
|
||||
beforeRevealMask.path = UIBezierPath(
|
||||
rect: CGRect(x: 0, y: 0, width: x, height: bounds.height)
|
||||
).cgPath
|
||||
dividerLine.frame = CGRect(x: x - 1.5, y: 0, width: 3, height: bounds.height)
|
||||
dividerHandle.frame = CGRect(x: x - 24, y: bounds.midY - 24, width: 48, height: 48)
|
||||
dividerHandle.accessibilityValue = "原图占比 \(Int((dividerFraction * 100).rounded()))%"
|
||||
}
|
||||
|
||||
private func updateDivider(locationX: CGFloat) {
|
||||
guard isReady, comparisonContainer.bounds.width > 0 else { return }
|
||||
dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(
|
||||
locationX / comparisonContainer.bounds.width
|
||||
)
|
||||
layoutDivider()
|
||||
}
|
||||
|
||||
private func adjustDivider(by delta: CGFloat) {
|
||||
stopAutomaticMotion()
|
||||
dividerFraction = BeforeAfterComparisonMotion.clampedManualFraction(dividerFraction + delta)
|
||||
layoutDivider()
|
||||
}
|
||||
|
||||
@objc private func dividerDragged(_ gesture: UILongPressGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
isDraggingDivider = true
|
||||
stopAutomaticMotion()
|
||||
dividerHandle.setActive(true, animated: !UIAccessibility.isReduceMotionEnabled)
|
||||
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
|
||||
case .changed:
|
||||
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
|
||||
case .ended, .cancelled, .failed:
|
||||
updateDivider(locationX: gesture.location(in: comparisonContainer).x)
|
||||
isDraggingDivider = false
|
||||
dividerHandle.setActive(false, animated: !UIAccessibility.isReduceMotionEnabled)
|
||||
startAutomaticMotion(from: dividerFraction)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func automaticMotionTick(_ link: CADisplayLink) {
|
||||
guard isReady, !isDraggingDivider, let animationStartTime else { return }
|
||||
dividerFraction = BeforeAfterComparisonMotion.automaticFraction(
|
||||
elapsed: link.timestamp - animationStartTime
|
||||
)
|
||||
layoutDivider()
|
||||
}
|
||||
|
||||
private func startAutomaticMotion(from fraction: CGFloat) {
|
||||
guard isReady,
|
||||
viewIfLoaded?.window != nil,
|
||||
!isDraggingDivider,
|
||||
!UIAccessibility.isReduceMotionEnabled
|
||||
else { return }
|
||||
animationStartTime = CACurrentMediaTime() - BeforeAfterComparisonMotion.phaseTime(for: fraction)
|
||||
if displayLink == nil {
|
||||
let link = CADisplayLink(target: self, selector: #selector(automaticMotionTick(_:)))
|
||||
link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
|
||||
link.add(to: .main, forMode: .common)
|
||||
displayLink = link
|
||||
}
|
||||
displayLink?.isPaused = false
|
||||
}
|
||||
|
||||
private func stopAutomaticMotion() {
|
||||
displayLink?.isPaused = true
|
||||
animationStartTime = nil
|
||||
}
|
||||
|
||||
@objc private func reduceMotionStatusChanged() {
|
||||
if UIAccessibility.isReduceMotionEnabled {
|
||||
stopAutomaticMotion()
|
||||
} else {
|
||||
startAutomaticMotion(from: dividerFraction)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func backTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func retryTapped() {
|
||||
loadImages()
|
||||
}
|
||||
}
|
||||
|
||||
/// 前后对比分隔线的可调节圆形手柄。
|
||||
private final class BeforeAfterComparisonHandleView: UIView {
|
||||
var onIncrement: (() -> Void)?
|
||||
var onDecrement: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor.black.withAlphaComponent(0.38)
|
||||
layer.cornerRadius = 24
|
||||
layer.borderColor = UIColor.white.withAlphaComponent(0.82).cgColor
|
||||
layer.borderWidth = 2
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.32
|
||||
layer.shadowRadius = 7
|
||||
layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
isAccessibilityElement = true
|
||||
accessibilityLabel = "前后对比分隔线"
|
||||
accessibilityTraits = [.adjustable]
|
||||
|
||||
let imageView = UIImageView(image: UIImage(systemName: "arrow.left.and.right"))
|
||||
imageView.tintColor = .white
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
addSubview(imageView)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(21)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新手柄按压态,为拖动接管提供轻微但不改变布局的视觉反馈。
|
||||
func setActive(_ active: Bool, animated: Bool) {
|
||||
let changes = {
|
||||
self.transform = active ? CGAffineTransform(scaleX: 1.08, y: 1.08) : .identity
|
||||
self.backgroundColor = UIColor.black.withAlphaComponent(active ? 0.52 : 0.38)
|
||||
}
|
||||
guard animated else {
|
||||
changes()
|
||||
return
|
||||
}
|
||||
UIView.animate(
|
||||
withDuration: 0.18,
|
||||
delay: 0,
|
||||
options: [.curveEaseOut, .beginFromCurrentState],
|
||||
animations: changes
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func accessibilityIncrement() {
|
||||
onIncrement?()
|
||||
}
|
||||
|
||||
override func accessibilityDecrement() {
|
||||
onDecrement?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 前后对比图片底部的高对比度胶囊角标。
|
||||
private final class BeforeAfterComparisonLabel: UILabel {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
textColor = .white
|
||||
font = .systemFont(ofSize: 12, weight: .medium)
|
||||
textAlignment = .center
|
||||
backgroundColor = UIColor.black.withAlphaComponent(0.5)
|
||||
layer.cornerRadius = 13
|
||||
layer.borderColor = UIColor.white.withAlphaComponent(0.2).cgColor
|
||||
layer.borderWidth = 0.5
|
||||
clipsToBounds = true
|
||||
setContentHuggingPriority(.required, for: .horizontal)
|
||||
layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let size = super.intrinsicContentSize
|
||||
return CGSize(width: size.width + 20, height: 26)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// AI 修图任务详情页,按设计稿展示状态总览、相册信息、任务内容与逐输出处理明细。
|
||||
final class TravelAlbumAIJobDetailViewController: BaseViewController {
|
||||
private let viewModel: TravelAlbumAIJobDetailViewModel
|
||||
private let api: any TravelAlbumServing
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let emptyView = AIJobDetailEmptyView()
|
||||
private var pollingTask: Task<Void, Never>?
|
||||
private var isVisible = false
|
||||
private var isPresentingLoading = false
|
||||
|
||||
/// 创建指定批次的任务详情。
|
||||
init(batchId: Int, api: (any TravelAlbumServing)? = nil) {
|
||||
viewModel = TravelAlbumAIJobDetailViewModel(batchId: batchId)
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func setupNavigationBar() {
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "任务详情"
|
||||
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
navigationItem.titleView = titleLabel
|
||||
navigationItem.backButtonDisplayMode = .minimal
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "arrow.clockwise"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(manualRefresh)
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.tintColor = AIJobDetailStyle.textPrimary
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "刷新任务详情"
|
||||
let appearance = UINavigationBarAppearance()
|
||||
appearance.configureWithOpaqueBackground()
|
||||
appearance.backgroundColor = .white
|
||||
appearance.shadowColor = .clear
|
||||
navigationItem.standardAppearance = appearance
|
||||
navigationItem.scrollEdgeAppearance = appearance
|
||||
navigationItem.compactAppearance = appearance
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AIJobDetailStyle.pageBackground
|
||||
scrollView.backgroundColor = .clear
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.refreshControl = refreshControl
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 14
|
||||
emptyView.onRetry = { [weak self] in self?.reload() }
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
view.addSubview(emptyView)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(scrollView.contentLayoutGuide).offset(12)
|
||||
make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(18)
|
||||
make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24)
|
||||
}
|
||||
emptyView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
|
||||
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
||||
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
|
||||
viewModel.onNotFound = { [weak self] in Task { @MainActor in self?.presentNotFound() } }
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationBecameActive),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationEnteredBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.load(api: api) }
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
isVisible = true
|
||||
updateLoadingPresentation()
|
||||
updatePolling()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
isVisible = false
|
||||
setLoadingPresented(false)
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
deinit {
|
||||
pollingTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyState() {
|
||||
refreshControl.endRefreshing()
|
||||
let unavailable = viewModel.detail == nil && !viewModel.isLoading
|
||||
emptyView.isHidden = !unavailable
|
||||
emptyView.apply(
|
||||
title: "任务详情加载失败",
|
||||
message: viewModel.errorMessage ?? "暂时无法获取任务信息,请稍后重试。"
|
||||
)
|
||||
scrollView.isHidden = viewModel.detail == nil
|
||||
if let detail = viewModel.detail { rebuildContent(detail) }
|
||||
updateLoadingPresentation()
|
||||
updatePolling()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func rebuildContent(_ detail: TravelAlbumAIJobDetail) {
|
||||
contentStack.arrangedSubviews.forEach {
|
||||
contentStack.removeArrangedSubview($0)
|
||||
$0.removeFromSuperview()
|
||||
}
|
||||
|
||||
let statusCard = AIJobDetailStatusCard()
|
||||
statusCard.apply(detail)
|
||||
contentStack.addArrangedSubview(statusCard)
|
||||
|
||||
let albumCard = AIJobDetailAlbumCard()
|
||||
albumCard.apply(detail)
|
||||
contentStack.addArrangedSubview(albumCard)
|
||||
|
||||
let contentCard = AIJobDetailContentCard()
|
||||
contentCard.apply(detail)
|
||||
contentStack.addArrangedSubview(contentCard)
|
||||
|
||||
let processingCard = AIJobDetailProcessingCard()
|
||||
processingCard.apply(
|
||||
detail.targets,
|
||||
onViewResult: { [weak self] target in self?.openResult(target) }
|
||||
)
|
||||
contentStack.addArrangedSubview(processingCard)
|
||||
|
||||
let albumButton = AIJobDetailAlbumButton()
|
||||
albumButton.addTarget(self, action: #selector(openAlbumTapped), for: .touchUpInside)
|
||||
contentStack.addArrangedSubview(albumButton)
|
||||
albumButton.snp.makeConstraints { $0.height.equalTo(56) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateLoadingPresentation() {
|
||||
setLoadingPresented(isVisible && viewModel.isLoading && viewModel.detail == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setLoadingPresented(_ presented: Bool) {
|
||||
guard isPresentingLoading != presented else { return }
|
||||
isPresentingLoading = presented
|
||||
presented ? showLoading() : hideLoading()
|
||||
}
|
||||
|
||||
private func reload() { Task { await viewModel.load(api: api) } }
|
||||
|
||||
@objc private func refreshTriggered() { Task { await viewModel.load(api: api, refreshing: true) } }
|
||||
@objc private func manualRefresh() { Task { await viewModel.load(api: api, refreshing: true) } }
|
||||
@objc private func applicationBecameActive() { updatePolling() }
|
||||
@objc private func applicationEnteredBackground() { stopPolling() }
|
||||
@objc private func openAlbumTapped() { openAlbum() }
|
||||
|
||||
private func updatePolling() {
|
||||
guard isVisible,
|
||||
UIApplication.shared.applicationState == .active,
|
||||
viewModel.shouldPoll,
|
||||
pollingTask == nil
|
||||
else {
|
||||
if !viewModel.shouldPoll { stopPolling() }
|
||||
return
|
||||
}
|
||||
pollingTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
guard !Task.isCancelled, let self else { break }
|
||||
await self.viewModel.load(api: self.api, silent: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPolling() {
|
||||
pollingTask?.cancel()
|
||||
pollingTask = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func presentNotFound() {
|
||||
guard presentedViewController == nil else { return }
|
||||
let alert = UIAlertController(title: nil, message: "任务不存在或已失效", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "知道了", style: .default) { [weak self] _ in
|
||||
self?.replaceWithTaskList()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func replaceWithTaskList() {
|
||||
guard let navigationController else { return }
|
||||
if let existing = navigationController.viewControllers.first(where: { $0 is TravelAlbumAIJobListViewController }) {
|
||||
navigationController.popToViewController(existing, animated: true)
|
||||
return
|
||||
}
|
||||
var stack = navigationController.viewControllers.filter { $0 !== self }
|
||||
stack.append(TravelAlbumAIJobListViewController(api: api))
|
||||
navigationController.setViewControllers(stack, animated: true)
|
||||
}
|
||||
|
||||
private func openAlbum() {
|
||||
guard let id = viewModel.detail?.userEquityTravelId, id > 0 else { return }
|
||||
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: id, api: api), animated: true)
|
||||
}
|
||||
|
||||
private func openResult(_ target: TravelAlbumAIJobTarget) {
|
||||
guard let kind = target.outputType.previewKind,
|
||||
let asset = target.resultAsset,
|
||||
!asset.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
showToast("结果资源已失效")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
var project: TravelAlbumPreviewProject
|
||||
if kind == .cover {
|
||||
project = TravelAlbumPreviewProject(
|
||||
originalMaterialId: asset.materialId,
|
||||
aiRetouchBatchId: viewModel.batchId,
|
||||
assets: [TravelAlbumPreviewAsset(
|
||||
id: "cover-\(asset.id)",
|
||||
kind: .cover,
|
||||
fileURL: asset.url,
|
||||
coverURL: asset.thumbnailURL,
|
||||
fileName: "AI封面",
|
||||
fileSize: 0
|
||||
)]
|
||||
)
|
||||
} else if let materialId = target.sourceMaterial?.id {
|
||||
do {
|
||||
let material = try await api.materialInfo(
|
||||
userEquityTravelId: viewModel.detail?.userEquityTravelId ?? 0,
|
||||
materialId: materialId
|
||||
)
|
||||
let current = TravelAlbumPreviewProject(material: material)
|
||||
let result = TravelAlbumPreviewAsset(
|
||||
id: "job-result-\(asset.id)",
|
||||
kind: kind,
|
||||
fileURL: asset.url,
|
||||
coverURL: asset.thumbnailURL,
|
||||
fileName: material.fileName,
|
||||
fileSize: material.fileSize
|
||||
)
|
||||
project = TravelAlbumPreviewProject(
|
||||
originalMaterialId: material.id,
|
||||
aiRetouchBatchId: viewModel.batchId,
|
||||
assets: current.assets + [result]
|
||||
)
|
||||
} catch {
|
||||
await MainActor.run { self.showToast("结果资源加载失败,请前往相册查看") }
|
||||
return
|
||||
}
|
||||
} else {
|
||||
await MainActor.run { self.showToast("结果资源已失效") }
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
self.present(
|
||||
TravelAlbumPhotoPreviewViewController(
|
||||
projects: [project],
|
||||
totalCount: 1,
|
||||
startProjectIndex: 0,
|
||||
startKind: kind,
|
||||
allowsActions: false
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图详情页专用视觉常量。
|
||||
private enum AIJobDetailStyle {
|
||||
static let pageBackground = UIColor(hex: 0xF5F7FB)
|
||||
static let textPrimary = UIColor(hex: 0x111827)
|
||||
static let textSecondary = UIColor(hex: 0x64748B)
|
||||
static let border = UIColor(hex: 0xE4EAF3)
|
||||
|
||||
static func styleCard(_ view: UIView) {
|
||||
view.backgroundColor = .white
|
||||
view.layer.cornerRadius = 14
|
||||
view.layer.borderWidth = 1
|
||||
view.layer.borderColor = border.cgColor
|
||||
view.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.08).cgColor
|
||||
view.layer.shadowOpacity = 1
|
||||
view.layer.shadowRadius = 8
|
||||
view.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务状态总览卡,包含环形状态、完成进度、ETA 和通知说明。
|
||||
private final class AIJobDetailStatusCard: UIView {
|
||||
private let ringView = AIJobCircularProgressView()
|
||||
private let statusLabel = UILabel()
|
||||
private let completedLabel = UILabel()
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
private let percentLabel = UILabel()
|
||||
private let timeIcon = UIImageView(image: UIImage(systemName: "clock"))
|
||||
private let timeLabel = UILabel()
|
||||
private let notificationIcon = UIImageView(image: UIImage(systemName: "bell"))
|
||||
private let notificationLabel = UILabel()
|
||||
private let timeRow = UIStackView()
|
||||
private let notificationRow = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
AIJobDetailStyle.styleCard(self)
|
||||
accessibilityIdentifier = "aiRetouchJob.detail.statusCard"
|
||||
statusLabel.font = .systemFont(ofSize: 23, weight: .semibold)
|
||||
completedLabel.font = .systemFont(ofSize: 15)
|
||||
completedLabel.textColor = AIJobDetailStyle.textSecondary
|
||||
progressView.trackTintColor = UIColor(hex: 0xE5EAF2)
|
||||
progressView.layer.cornerRadius = 3
|
||||
progressView.clipsToBounds = true
|
||||
percentLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
percentLabel.textColor = AIJobDetailStyle.textSecondary
|
||||
percentLabel.textAlignment = .right
|
||||
[timeIcon, notificationIcon].forEach {
|
||||
$0.tintColor = AppColor.primary
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.snp.makeConstraints { $0.size.equalTo(17) }
|
||||
}
|
||||
timeLabel.font = .systemFont(ofSize: 14)
|
||||
timeLabel.textColor = AIJobDetailStyle.textSecondary
|
||||
notificationLabel.font = .systemFont(ofSize: 14)
|
||||
notificationLabel.textColor = AIJobDetailStyle.textSecondary
|
||||
timeRow.axis = .horizontal
|
||||
timeRow.spacing = 8
|
||||
timeRow.alignment = .center
|
||||
timeRow.addArrangedSubview(timeIcon)
|
||||
timeRow.addArrangedSubview(timeLabel)
|
||||
notificationRow.axis = .horizontal
|
||||
notificationRow.spacing = 8
|
||||
notificationRow.alignment = .center
|
||||
notificationRow.addArrangedSubview(notificationIcon)
|
||||
notificationRow.addArrangedSubview(notificationLabel)
|
||||
|
||||
let progressRow = UIStackView(arrangedSubviews: [progressView, percentLabel])
|
||||
progressRow.axis = .horizontal
|
||||
progressRow.spacing = 10
|
||||
progressRow.alignment = .center
|
||||
progressView.snp.makeConstraints { $0.height.equalTo(7) }
|
||||
percentLabel.snp.makeConstraints { $0.width.greaterThanOrEqualTo(38) }
|
||||
let rightStack = UIStackView(arrangedSubviews: [statusLabel, completedLabel, progressRow, timeRow, notificationRow])
|
||||
rightStack.axis = .vertical
|
||||
rightStack.spacing = 7
|
||||
addSubview(ringView)
|
||||
addSubview(rightStack)
|
||||
ringView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(92)
|
||||
}
|
||||
rightStack.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(16)
|
||||
make.leading.equalTo(ringView.snp.trailing).offset(20)
|
||||
make.trailing.equalToSuperview().inset(18)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func apply(_ detail: TravelAlbumAIJobDetail) {
|
||||
let color = detail.status.semanticColor
|
||||
statusLabel.text = detail.status.title
|
||||
statusLabel.textColor = color
|
||||
completedLabel.text = "已完成 \(detail.progress.completed) / \(detail.progress.total)"
|
||||
progressView.progressTintColor = color
|
||||
progressView.progress = Float(detail.progress.fraction)
|
||||
percentLabel.text = "\(Int((detail.progress.fraction * 100).rounded()))%"
|
||||
ringView.apply(progress: detail.progress.fraction, color: color, status: detail.status)
|
||||
if detail.status.isInProgress {
|
||||
timeLabel.text = detail.estimatedFinishAt.map {
|
||||
"预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成"
|
||||
} ?? "完成时间暂无法预估"
|
||||
notificationLabel.text = "完成后将通过消息通知你"
|
||||
notificationRow.isHidden = false
|
||||
} else {
|
||||
timeLabel.text = "完成时间 \(TravelAlbumAIJobDateFormatter.display(detail.finishedAt))"
|
||||
notificationRow.isHidden = true
|
||||
}
|
||||
accessibilityLabel = [statusLabel.text, completedLabel.text, percentLabel.text, timeLabel.text, notificationLabel.text]
|
||||
.compactMap { $0 }.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
/// 环形进度组件,使用图形、文字与颜色共同表达任务状态。
|
||||
private final class AIJobCircularProgressView: UIView {
|
||||
private let trackLayer = CAShapeLayer()
|
||||
private let progressLayer = CAShapeLayer()
|
||||
private let iconView = UIImageView(image: UIImage(systemName: "sparkles"))
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
layer.addSublayer(trackLayer)
|
||||
layer.addSublayer(progressLayer)
|
||||
[trackLayer, progressLayer].forEach {
|
||||
$0.fillColor = UIColor.clear.cgColor
|
||||
$0.lineWidth = 8
|
||||
$0.lineCap = .round
|
||||
}
|
||||
trackLayer.strokeColor = UIColor(hex: 0xE7F0FF).cgColor
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
addSubview(iconView)
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(37)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
let radius = max(0, min(bounds.width, bounds.height) / 2 - 6)
|
||||
let path = UIBezierPath(
|
||||
arcCenter: center,
|
||||
radius: radius,
|
||||
startAngle: -.pi / 2,
|
||||
endAngle: .pi * 1.5,
|
||||
clockwise: true
|
||||
).cgPath
|
||||
trackLayer.path = path
|
||||
progressLayer.path = path
|
||||
}
|
||||
|
||||
func apply(progress: Double, color: UIColor, status: TravelAlbumAIJobStatus) {
|
||||
progressLayer.strokeColor = color.cgColor
|
||||
progressLayer.strokeEnd = max(0.04, min(1, progress))
|
||||
iconView.tintColor = color
|
||||
iconView.image = UIImage(systemName: status.isInProgress ? "sparkles" : status == .succeeded ? "checkmark" : "exclamationmark")
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册与任务信息卡,匹配设计稿中的封面、账号与提交信息结构。
|
||||
private final class AIJobDetailAlbumCard: UIView {
|
||||
private let coverView = UIImageView()
|
||||
private let albumLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let taskLabel = UILabel()
|
||||
private let submitLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
AIJobDetailStyle.styleCard(self)
|
||||
accessibilityIdentifier = "aiRetouchJob.detail.albumCard"
|
||||
coverView.contentMode = .scaleAspectFill
|
||||
coverView.clipsToBounds = true
|
||||
coverView.layer.cornerRadius = 12
|
||||
coverView.backgroundColor = AppColor.pageBackground
|
||||
coverView.tintColor = AppColor.textTertiary
|
||||
albumLabel.font = .systemFont(ofSize: 19, weight: .semibold)
|
||||
albumLabel.textColor = AIJobDetailStyle.textPrimary
|
||||
phoneLabel.font = .systemFont(ofSize: 14)
|
||||
phoneLabel.textColor = AIJobDetailStyle.textSecondary
|
||||
taskLabel.font = .systemFont(ofSize: 14)
|
||||
taskLabel.textColor = AIJobDetailStyle.textSecondary
|
||||
submitLabel.font = .systemFont(ofSize: 14)
|
||||
submitLabel.textColor = AIJobDetailStyle.textSecondary
|
||||
addSubview(coverView)
|
||||
addSubview(albumLabel)
|
||||
addSubview(phoneLabel)
|
||||
addSubview(taskLabel)
|
||||
addSubview(submitLabel)
|
||||
coverView.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(16)
|
||||
make.size.equalTo(84)
|
||||
}
|
||||
albumLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(coverView.snp.trailing).offset(14)
|
||||
make.top.equalToSuperview().offset(19)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(albumLabel)
|
||||
make.top.equalTo(albumLabel.snp.bottom).offset(7)
|
||||
}
|
||||
taskLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(albumLabel)
|
||||
make.bottom.equalToSuperview().inset(19)
|
||||
}
|
||||
submitLabel.snp.makeConstraints { make in
|
||||
make.leading.greaterThanOrEqualTo(taskLabel.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.centerY.equalTo(taskLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func apply(_ detail: TravelAlbumAIJobDetail) {
|
||||
coverView.kf.setImage(with: URL(string: detail.album.coverURL), placeholder: UIImage(systemName: "photo"))
|
||||
albumLabel.text = detail.album.name.isEmpty ? "未命名相册" : detail.album.name
|
||||
let phone = TravelAlbumDisplayFormatter.maskPhone(detail.album.userPhone)
|
||||
phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone
|
||||
taskLabel.text = "任务 #\(detail.aiRetouchBatchId)"
|
||||
submitLabel.text = "\(TravelAlbumAIJobDateFormatter.display(detail.createdAt)) 提交"
|
||||
accessibilityLabel = [albumLabel.text, phoneLabel.text, taskLabel.text, submitLabel.text]
|
||||
.compactMap { $0 }.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务内容卡,以三色紧凑标签展示各输出目标数量并补充额度结算。
|
||||
private final class AIJobDetailContentCard: UIView {
|
||||
private let outputStack = UIStackView()
|
||||
private let quotaLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
AIJobDetailStyle.styleCard(self)
|
||||
accessibilityIdentifier = "aiRetouchJob.detail.contentCard"
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "任务内容"
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||
outputStack.axis = .horizontal
|
||||
outputStack.spacing = 10
|
||||
outputStack.distribution = .fill
|
||||
outputStack.alignment = .center
|
||||
quotaLabel.font = .systemFont(ofSize: 12)
|
||||
quotaLabel.textColor = AIJobDetailStyle.textSecondary
|
||||
quotaLabel.numberOfLines = 0
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, outputStack, quotaLabel])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 14
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func apply(_ detail: TravelAlbumAIJobDetail) {
|
||||
outputStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
detail.outputs.forEach { outputStack.addArrangedSubview(makeChip($0)) }
|
||||
if let lastChip = outputStack.arrangedSubviews.last {
|
||||
let spacer = UIView()
|
||||
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
outputStack.setCustomSpacing(0, after: lastChip)
|
||||
outputStack.addArrangedSubview(spacer)
|
||||
}
|
||||
outputStack.isHidden = detail.outputs.isEmpty
|
||||
let quota = detail.quotaSettlement
|
||||
quotaLabel.text = "额度:预占 \(quota.reservedUnits) · 消耗 \(quota.consumedUnits) · 释放 \(quota.releasedUnits)"
|
||||
accessibilityLabel = ["任务内容", detail.outputs.map { "\($0.type.title) \($0.count)张" }.joined(separator: ","), quotaLabel.text]
|
||||
.compactMap { $0 }.joined(separator: ",")
|
||||
}
|
||||
|
||||
private func makeChip(_ output: TravelAlbumAIJobOutput) -> UIView {
|
||||
AIJobDetailOutputChip(
|
||||
text: "\(output.type.shortTitle) \(output.count)张",
|
||||
backgroundColor: output.type.chipBackgroundColor,
|
||||
accessibilityIdentifier: "aiRetouchJob.contentChip.\(output.type.chipIdentifier)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 依据文字固有宽度展示的任务输出类型标签。
|
||||
private final class AIJobDetailOutputChip: UIView {
|
||||
private let label = UILabel()
|
||||
|
||||
init(text: String, backgroundColor: UIColor, accessibilityIdentifier: String) {
|
||||
super.init(frame: .zero)
|
||||
self.backgroundColor = backgroundColor
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
layer.cornerRadius = 9
|
||||
setContentHuggingPriority(.required, for: .horizontal)
|
||||
setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
label.textColor = AIJobDetailStyle.textPrimary
|
||||
label.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(12)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
snp.makeConstraints { $0.height.equalTo(44) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: ceil(label.intrinsicContentSize.width) + 24, height: 44)
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理明细卡,将逐照片、逐输出状态及失败原因放在同一卡片中展示。
|
||||
private final class AIJobDetailProcessingCard: UIView {
|
||||
private let rowsStack = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
AIJobDetailStyle.styleCard(self)
|
||||
accessibilityIdentifier = "aiRetouchJob.detail.processingCard"
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "处理明细"
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .semibold)
|
||||
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||
rowsStack.axis = .vertical
|
||||
rowsStack.spacing = 0
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, rowsStack])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(16) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func apply(
|
||||
_ targets: [TravelAlbumAIJobTarget],
|
||||
onViewResult: @escaping (TravelAlbumAIJobTarget) -> Void
|
||||
) {
|
||||
rowsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
if targets.isEmpty {
|
||||
let label = UILabel()
|
||||
label.text = "暂无处理明细"
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.textColor = AIJobDetailStyle.textSecondary
|
||||
label.textAlignment = .center
|
||||
label.snp.makeConstraints { $0.height.equalTo(52) }
|
||||
rowsStack.addArrangedSubview(label)
|
||||
return
|
||||
}
|
||||
for (index, target) in targets.enumerated() {
|
||||
let row = AIJobDetailTargetRow()
|
||||
row.apply(target)
|
||||
row.onViewResult = { onViewResult(target) }
|
||||
rowsStack.addArrangedSubview(row)
|
||||
if index < targets.count - 1 {
|
||||
let separator = UIView()
|
||||
separator.backgroundColor = AIJobDetailStyle.border
|
||||
separator.snp.makeConstraints { $0.height.equalTo(1) }
|
||||
rowsStack.addArrangedSubview(separator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个输出明细行,内联展示结果操作或后端返回的失败原因。
|
||||
private final class AIJobDetailTargetRow: UIView {
|
||||
private let thumbnailView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let templateLabel = UILabel()
|
||||
private let statusIconView = UIImageView()
|
||||
private let statusLabel = UILabel()
|
||||
private let statusStack = UIStackView()
|
||||
private let statusRow = UIStackView()
|
||||
private let contentStack = UIStackView()
|
||||
private let resultButton = UIButton(type: .system)
|
||||
private let errorContainer = UIView()
|
||||
private let errorIconView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
||||
private let errorLabel = UILabel()
|
||||
var onViewResult: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
thumbnailView.contentMode = .scaleAspectFill
|
||||
thumbnailView.clipsToBounds = true
|
||||
thumbnailView.layer.cornerRadius = 10
|
||||
thumbnailView.backgroundColor = AppColor.pageBackground
|
||||
thumbnailView.tintColor = AppColor.textTertiary
|
||||
titleLabel.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
titleLabel.textColor = AIJobDetailStyle.textPrimary
|
||||
titleLabel.lineBreakMode = .byTruncatingMiddle
|
||||
titleLabel.numberOfLines = 1
|
||||
templateLabel.font = .systemFont(ofSize: 13)
|
||||
templateLabel.textColor = AIJobDetailStyle.textSecondary
|
||||
templateLabel.lineBreakMode = .byTruncatingTail
|
||||
templateLabel.numberOfLines = 1
|
||||
statusIconView.contentMode = .scaleAspectFit
|
||||
statusLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
resultButton.setTitle("查看结果", for: .normal)
|
||||
resultButton.titleLabel?.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
resultButton.addTarget(self, action: #selector(viewResultTapped), for: .touchUpInside)
|
||||
errorContainer.backgroundColor = AppColor.dangerBackground
|
||||
errorContainer.layer.cornerRadius = 8
|
||||
errorIconView.tintColor = AppColor.danger
|
||||
errorLabel.font = .systemFont(ofSize: 13)
|
||||
errorLabel.textColor = AppColor.danger
|
||||
errorLabel.numberOfLines = 0
|
||||
|
||||
statusStack.addArrangedSubview(statusIconView)
|
||||
statusStack.addArrangedSubview(statusLabel)
|
||||
statusStack.axis = .horizontal
|
||||
statusStack.spacing = 6
|
||||
statusStack.alignment = .center
|
||||
statusStack.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
statusLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
let statusSpacer = UIView()
|
||||
statusRow.addArrangedSubview(statusStack)
|
||||
statusRow.addArrangedSubview(statusSpacer)
|
||||
statusRow.addArrangedSubview(resultButton)
|
||||
statusRow.axis = .horizontal
|
||||
statusRow.spacing = 8
|
||||
statusRow.alignment = .center
|
||||
contentStack.addArrangedSubview(titleLabel)
|
||||
contentStack.addArrangedSubview(templateLabel)
|
||||
contentStack.addArrangedSubview(statusRow)
|
||||
contentStack.addArrangedSubview(errorContainer)
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 0
|
||||
contentStack.setCustomSpacing(7, after: titleLabel)
|
||||
contentStack.setCustomSpacing(8, after: templateLabel)
|
||||
contentStack.setCustomSpacing(12, after: statusRow)
|
||||
statusIconView.snp.makeConstraints { $0.size.equalTo(18) }
|
||||
addSubview(thumbnailView)
|
||||
addSubview(contentStack)
|
||||
errorContainer.addSubview(errorIconView)
|
||||
errorContainer.addSubview(errorLabel)
|
||||
|
||||
thumbnailView.snp.makeConstraints { make in
|
||||
make.leading.top.equalToSuperview().offset(4)
|
||||
make.size.equalTo(80)
|
||||
make.bottom.lessThanOrEqualToSuperview().inset(12)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.leading.equalTo(thumbnailView.snp.trailing).offset(14)
|
||||
make.top.equalTo(thumbnailView).offset(18)
|
||||
make.trailing.equalToSuperview().inset(4)
|
||||
make.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
errorIconView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(10)
|
||||
make.top.equalToSuperview().offset(11)
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
errorLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(errorIconView.snp.trailing).offset(7)
|
||||
make.top.bottom.equalToSuperview().inset(9)
|
||||
make.trailing.equalToSuperview().inset(10)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func apply(_ target: TravelAlbumAIJobTarget) {
|
||||
let thumbnail = target.sourceMaterial?.thumbnailURL ?? target.resultAsset?.thumbnailURL ?? ""
|
||||
thumbnailView.kf.setImage(with: URL(string: thumbnail), placeholder: UIImage(systemName: "photo"))
|
||||
titleLabel.text = target.outputType == .cover
|
||||
? "相册封面"
|
||||
: (target.sourceMaterial?.fileName.isEmpty == false ? target.sourceMaterial?.fileName : "照片 \(target.sourceMaterial?.id ?? 0)")
|
||||
templateLabel.text = target.template?.name ?? target.outputType.title
|
||||
let color = target.status.semanticColor
|
||||
statusIconView.tintColor = color
|
||||
statusLabel.textColor = color
|
||||
statusIconView.image = UIImage(systemName: target.status.symbolName)
|
||||
switch target.status {
|
||||
case .succeeded:
|
||||
statusLabel.text = "\(target.outputType.shortTitle)完成"
|
||||
case .processing:
|
||||
statusLabel.text = "正在生成"
|
||||
case .queued:
|
||||
statusLabel.text = "等待处理"
|
||||
case .failed:
|
||||
statusLabel.text = "生成失败"
|
||||
default:
|
||||
statusLabel.text = target.status.title
|
||||
}
|
||||
let hasResult = target.status == .succeeded && target.resultAsset?.url.isEmpty == false
|
||||
resultButton.isHidden = !hasResult
|
||||
errorLabel.text = target.displayFailureMessage.map { "失败原因:\($0)" }
|
||||
let showsError = target.displayFailureMessage != nil
|
||||
errorContainer.isHidden = !showsError
|
||||
titleLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).title"
|
||||
templateLabel.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).template"
|
||||
statusStack.accessibilityIdentifier = "aiRetouchJob.target.\(target.targetId).status"
|
||||
accessibilityLabel = [titleLabel.text, templateLabel.text, statusLabel.text, errorLabel.text]
|
||||
.compactMap { $0 }.joined(separator: ",")
|
||||
}
|
||||
|
||||
@objc private func viewResultTapped() { onViewResult?() }
|
||||
}
|
||||
|
||||
/// 页面底部的查看相册主操作按钮。
|
||||
private final class AIJobDetailAlbumButton: UIButton {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.title = "查看相册"
|
||||
configuration.image = UIImage(systemName: "photo")
|
||||
configuration.imagePadding = 10
|
||||
configuration.baseForegroundColor = AppColor.primary
|
||||
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
return outgoing
|
||||
}
|
||||
self.configuration = configuration
|
||||
layer.cornerRadius = 10
|
||||
layer.borderWidth = 1.5
|
||||
layer.borderColor = AppColor.primary.cgColor
|
||||
backgroundColor = .white
|
||||
accessibilityIdentifier = "aiRetouchJob.detail.albumButton"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
}
|
||||
|
||||
/// 任务详情加载失败时的空态。
|
||||
private final class AIJobDetailEmptyView: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let messageLabel = UILabel()
|
||||
private let button = UIButton(type: .system)
|
||||
var onRetry: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
titleLabel.textAlignment = .center
|
||||
messageLabel.font = .systemFont(ofSize: 14)
|
||||
messageLabel.textColor = AppColor.textSecondary
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.numberOfLines = 0
|
||||
button.setTitle("重新加载", for: .normal)
|
||||
button.addTarget(self, action: #selector(retry), for: .touchUpInside)
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, button])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 12
|
||||
stack.alignment = .center
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(40)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func apply(title: String, message: String) { titleLabel.text = title; messageLabel.text = message }
|
||||
@objc private func retry() { onRetry?() }
|
||||
}
|
||||
|
||||
private extension TravelAlbumAIJobStatus {
|
||||
var semanticColor: UIColor {
|
||||
switch self {
|
||||
case .queued, .processing: AppColor.primary
|
||||
case .succeeded: AppColor.success
|
||||
case .partiallySucceeded: AppColor.warning
|
||||
case .failed: AppColor.danger
|
||||
case .canceled, .unknown: AppColor.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .queued: "clock"
|
||||
case .processing: "arrow.triangle.2.circlepath"
|
||||
case .succeeded: "checkmark.circle.fill"
|
||||
case .partiallySucceeded: "exclamationmark.circle.fill"
|
||||
case .failed: "exclamationmark.circle"
|
||||
case .canceled, .unknown: "minus.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension TravelAlbumAIJobOutputType {
|
||||
var shortTitle: String {
|
||||
switch self {
|
||||
case .refined: "精修"
|
||||
case .atmosphere: "氛围感"
|
||||
case .cover: "封面"
|
||||
case .unknown: "其他"
|
||||
}
|
||||
}
|
||||
|
||||
var chipBackgroundColor: UIColor {
|
||||
switch self {
|
||||
case .refined: UIColor(hex: 0xEAF3FF)
|
||||
case .atmosphere: UIColor(hex: 0xFFF2DC)
|
||||
case .cover: UIColor(hex: 0xF2ECFF)
|
||||
case .unknown: UIColor(hex: 0xF1F5F9)
|
||||
}
|
||||
}
|
||||
|
||||
var chipIdentifier: String {
|
||||
switch self {
|
||||
case .refined: "refined"
|
||||
case .atmosphere: "atmosphere"
|
||||
case .cover: "cover"
|
||||
case .unknown: "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 当前账号的 AI 修图任务中心,提供筛选、游标分页、刷新与状态轮询。
|
||||
final class TravelAlbumAIJobListViewController: BaseViewController {
|
||||
private enum Section { case main }
|
||||
|
||||
private let viewModel = TravelAlbumAIJobListViewModel()
|
||||
private let api: any TravelAlbumServing
|
||||
private let filterContainer = UIView()
|
||||
private let filterStack = UIStackView()
|
||||
private var filterButtons: [TravelAlbumAIJobFilter: UIButton] = [:]
|
||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let emptyView = AIJobEmptyView()
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>!
|
||||
private var pollingTask: Task<Void, Never>?
|
||||
private var isVisible = false
|
||||
private var isPresentingLoading = false
|
||||
|
||||
/// 创建任务中心,可注入 API 以支持测试。
|
||||
init(api: (any TravelAlbumServing)? = nil) {
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func setupNavigationBar() {
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "AI修图任务"
|
||||
titleLabel.textColor = UIColor(hex: 0x0F1F3D)
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
navigationItem.titleView = titleLabel
|
||||
navigationItem.backButtonDisplayMode = .minimal
|
||||
let appearance = UINavigationBarAppearance()
|
||||
appearance.configureWithOpaqueBackground()
|
||||
appearance.backgroundColor = .white
|
||||
appearance.shadowColor = .clear
|
||||
navigationItem.standardAppearance = appearance
|
||||
navigationItem.scrollEdgeAppearance = appearance
|
||||
navigationItem.compactAppearance = appearance
|
||||
navigationController?.setNavigationBarHidden(false, animated: false)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackgroundSoft
|
||||
configureFilters()
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.alwaysBounceVertical = true
|
||||
collectionView.delegate = self
|
||||
collectionView.refreshControl = refreshControl
|
||||
collectionView.register(AIJobSummaryCell.self, forCellWithReuseIdentifier: AIJobSummaryCell.reuseIdentifier)
|
||||
dataSource = UICollectionViewDiffableDataSource<Section, TravelAlbumAIJobSummary>(collectionView: collectionView) {
|
||||
collectionView, indexPath, item in
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: AIJobSummaryCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! AIJobSummaryCell
|
||||
cell.apply(item)
|
||||
return cell
|
||||
}
|
||||
emptyView.onRetry = { [weak self] in self?.reload() }
|
||||
view.addSubview(filterContainer)
|
||||
filterContainer.addSubview(filterStack)
|
||||
view.addSubview(collectionView)
|
||||
view.addSubview(emptyView)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
filterContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(20)
|
||||
make.height.equalTo(46)
|
||||
}
|
||||
filterStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(2)
|
||||
}
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(filterContainer.snp.bottom).offset(14)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
emptyView.snp.makeConstraints { make in
|
||||
make.top.equalTo(filterContainer.snp.bottom)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
refreshControl.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
|
||||
viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.applyState() } }
|
||||
viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } }
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationBecameActive),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationEnteredBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.loadFirstPage(api: api) }
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
isVisible = true
|
||||
updateLoadingPresentation()
|
||||
updatePolling()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
isVisible = false
|
||||
setLoadingPresented(false)
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
deinit {
|
||||
pollingTask?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private func configureFilters() {
|
||||
filterContainer.accessibilityIdentifier = "aiRetouchJob.filterContainer"
|
||||
filterContainer.backgroundColor = .white
|
||||
filterContainer.layer.cornerRadius = 12
|
||||
filterContainer.layer.borderWidth = 1
|
||||
filterContainer.layer.borderColor = UIColor(hex: 0xE8EEF7).cgColor
|
||||
filterContainer.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.12).cgColor
|
||||
filterContainer.layer.shadowOpacity = 1
|
||||
filterContainer.layer.shadowRadius = 7
|
||||
filterContainer.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
filterStack.axis = .horizontal
|
||||
filterStack.spacing = 0
|
||||
filterStack.distribution = .fillEqually
|
||||
TravelAlbumAIJobFilter.allCases.forEach { filter in
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.title = filter.title
|
||||
configuration.contentInsets = .zero
|
||||
let button = UIButton(configuration: configuration)
|
||||
button.tag = TravelAlbumAIJobFilter.allCases.firstIndex(of: filter) ?? 0
|
||||
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
button.layer.cornerRadius = 10
|
||||
button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside)
|
||||
button.accessibilityLabel = "筛选:\(filter.title)"
|
||||
filterButtons[filter] = button
|
||||
filterStack.addArrangedSubview(button)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeLayout() -> UICollectionViewLayout {
|
||||
UICollectionViewCompositionalLayout { _, environment in
|
||||
let item = NSCollectionLayoutItem(layoutSize: .init(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .estimated(250)
|
||||
))
|
||||
let group = NSCollectionLayoutGroup.vertical(
|
||||
layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(250)),
|
||||
subitems: [item]
|
||||
)
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.interGroupSpacing = 14
|
||||
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 24, trailing: 20)
|
||||
return section
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyState() {
|
||||
filterButtons.forEach { filter, button in
|
||||
let selected = filter == viewModel.selectedFilter
|
||||
button.configuration?.baseForegroundColor = selected ? .white : AppColor.textSecondary
|
||||
button.configuration?.background.backgroundColor = .clear
|
||||
button.backgroundColor = selected ? AppColor.primary : .clear
|
||||
button.accessibilityTraits = selected ? [.button, .selected] : .button
|
||||
}
|
||||
refreshControl.endRefreshing()
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, TravelAlbumAIJobSummary>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(viewModel.items)
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
let empty = viewModel.items.isEmpty && !viewModel.isLoading
|
||||
emptyView.isHidden = !empty
|
||||
emptyView.apply(
|
||||
title: viewModel.errorMessage == nil ? "暂无AI修图任务" : "任务加载失败",
|
||||
message: viewModel.errorMessage ?? "提交AI修图后,可以在这里查看进度和结果。",
|
||||
showsRetry: viewModel.errorMessage != nil
|
||||
)
|
||||
updateLoadingPresentation()
|
||||
updatePolling()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateLoadingPresentation() {
|
||||
setLoadingPresented(isVisible && viewModel.isLoading && viewModel.items.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setLoadingPresented(_ presented: Bool) {
|
||||
guard isPresentingLoading != presented else { return }
|
||||
isPresentingLoading = presented
|
||||
if presented {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private func reload() { Task { await viewModel.loadFirstPage(api: api) } }
|
||||
|
||||
@objc private func refreshTriggered() {
|
||||
Task { await viewModel.loadFirstPage(api: api, refreshing: true) }
|
||||
}
|
||||
|
||||
@objc private func filterTapped(_ sender: UIButton) {
|
||||
guard TravelAlbumAIJobFilter.allCases.indices.contains(sender.tag) else { return }
|
||||
Task { await viewModel.selectFilter(TravelAlbumAIJobFilter.allCases[sender.tag], api: api) }
|
||||
}
|
||||
|
||||
@objc private func applicationBecameActive() { updatePolling() }
|
||||
@objc private func applicationEnteredBackground() { stopPolling() }
|
||||
|
||||
private func updatePolling() {
|
||||
guard isVisible,
|
||||
UIApplication.shared.applicationState == .active,
|
||||
viewModel.containsInProgressJobs,
|
||||
pollingTask == nil
|
||||
else {
|
||||
if !viewModel.containsInProgressJobs { stopPolling() }
|
||||
return
|
||||
}
|
||||
pollingTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(15))
|
||||
guard !Task.isCancelled, let self else { break }
|
||||
await self.viewModel.loadFirstPage(api: self.api, silent: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPolling() {
|
||||
pollingTask?.cancel()
|
||||
pollingTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIJobListViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
navigationController?.pushViewController(
|
||||
TravelAlbumAIJobDetailViewController(batchId: item.aiRetouchBatchId, api: api),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView.contentSize.height > 0,
|
||||
scrollView.contentOffset.y + scrollView.bounds.height > scrollView.contentSize.height - 240
|
||||
else { return }
|
||||
Task { await viewModel.loadMore(api: api) }
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图任务列表卡片。
|
||||
private final class AIJobSummaryCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "AIJobSummaryCell"
|
||||
private let topInfoView = UIView()
|
||||
private let albumCoverImageView = UIImageView()
|
||||
private let albumLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let taskLabel = UILabel()
|
||||
private let timeIconView = UIImageView(image: UIImage(systemName: "clock"))
|
||||
private let timeLabel = UILabel()
|
||||
private let timeStack = UIStackView()
|
||||
private let statusBadge = AIJobStatusBadgeView()
|
||||
private let previewStack = UIStackView()
|
||||
private var previewImageViews: [UIImageView] = []
|
||||
private let progressRow = UIStackView()
|
||||
private let completedLabel = UILabel()
|
||||
private let outputLabel = UILabel()
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
private let percentLabel = UILabel()
|
||||
private let etaLabel = UILabel()
|
||||
private let bottomRow = UIView()
|
||||
private let outputPill = UIView()
|
||||
private let outputIconView = UIImageView(image: UIImage(systemName: "wand.and.stars"))
|
||||
private let detailsLabel = UILabel()
|
||||
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.right"))
|
||||
private let contentStack = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
contentView.layer.cornerRadius = 16
|
||||
contentView.layer.borderWidth = 1
|
||||
contentView.layer.borderColor = UIColor(hex: 0xE6EDF8).cgColor
|
||||
contentView.layer.shadowColor = UIColor(hex: 0x315B94).withAlphaComponent(0.1).cgColor
|
||||
contentView.layer.shadowOpacity = 1
|
||||
contentView.layer.shadowRadius = 9
|
||||
contentView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
|
||||
configureTopInfo()
|
||||
configurePreviewGrid()
|
||||
|
||||
completedLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
completedLabel.textColor = UIColor(hex: 0x263754)
|
||||
progressView.tintColor = AppColor.primary
|
||||
progressView.trackTintColor = UIColor(hex: 0xE5EAF2)
|
||||
progressView.layer.cornerRadius = 3
|
||||
progressView.clipsToBounds = true
|
||||
percentLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
percentLabel.textColor = UIColor(hex: 0x263754)
|
||||
percentLabel.textAlignment = .right
|
||||
progressRow.axis = .horizontal
|
||||
progressRow.alignment = .center
|
||||
progressRow.spacing = 10
|
||||
progressRow.addArrangedSubview(completedLabel)
|
||||
progressRow.addArrangedSubview(progressView)
|
||||
progressRow.addArrangedSubview(percentLabel)
|
||||
progressView.snp.makeConstraints { $0.height.equalTo(6) }
|
||||
completedLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
percentLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
|
||||
etaLabel.font = .systemFont(ofSize: 13)
|
||||
etaLabel.textColor = UIColor(hex: 0x7F8CA3)
|
||||
configureBottomRow()
|
||||
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 12
|
||||
[topInfoView, previewStack, progressRow, etaLabel, bottomRow].forEach {
|
||||
contentStack.addArrangedSubview($0)
|
||||
}
|
||||
contentView.addSubview(contentStack)
|
||||
contentStack.snp.makeConstraints { $0.edges.equalToSuperview().inset(14) }
|
||||
topInfoView.snp.makeConstraints { $0.height.equalTo(72) }
|
||||
previewStack.snp.makeConstraints { $0.height.equalTo(104) }
|
||||
bottomRow.snp.makeConstraints { $0.height.equalTo(34) }
|
||||
accessibilityIdentifier = "aiRetouchJob.summaryCell"
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
albumCoverImageView.kf.cancelDownloadTask()
|
||||
albumCoverImageView.image = nil
|
||||
previewImageViews.forEach {
|
||||
$0.kf.cancelDownloadTask()
|
||||
$0.image = nil
|
||||
$0.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
func apply(_ item: TravelAlbumAIJobSummary) {
|
||||
albumLabel.text = item.album.name.isEmpty ? "未命名相册" : item.album.name
|
||||
let phone = TravelAlbumDisplayFormatter.maskPhone(item.album.userPhone)
|
||||
phoneLabel.text = phone.isEmpty ? "未提供手机号" : phone
|
||||
taskLabel.text = "任务 #\(item.aiRetouchBatchId)"
|
||||
timeLabel.text = relativeTime(item.createdAt)
|
||||
statusBadge.apply(item.status)
|
||||
albumCoverImageView.kf.setImage(
|
||||
with: URL(string: item.album.coverURL),
|
||||
placeholder: UIImage(systemName: "photo")
|
||||
)
|
||||
previewImageViews.enumerated().forEach { index, imageView in
|
||||
guard item.previewImages.indices.contains(index) else {
|
||||
imageView.isHidden = true
|
||||
return
|
||||
}
|
||||
imageView.isHidden = false
|
||||
imageView.kf.setImage(
|
||||
with: URL(string: item.previewImages[index].thumbnailURL),
|
||||
placeholder: UIImage(systemName: "photo")
|
||||
)
|
||||
}
|
||||
previewStack.isHidden = item.previewImages.isEmpty
|
||||
|
||||
progressView.progress = Float(item.progress.fraction)
|
||||
if item.status.isInProgress {
|
||||
completedLabel.text = "已完成 \(item.progress.completed) / \(item.progress.total)"
|
||||
percentLabel.text = "\(Int((item.progress.fraction * 100).rounded()))%"
|
||||
etaLabel.text = item.estimatedFinishAt.map {
|
||||
"预计 \(TravelAlbumAIJobDateFormatter.time($0)) 前完成"
|
||||
} ?? "完成后将通过消息通知"
|
||||
progressRow.isHidden = false
|
||||
etaLabel.isHidden = false
|
||||
outputIconView.image = UIImage(systemName: "wand.and.stars")
|
||||
outputIconView.tintColor = AppColor.primary
|
||||
outputPill.backgroundColor = AppColor.primaryLight
|
||||
outputLabel.textColor = AppColor.primary
|
||||
outputLabel.text = item.outputs.map { "\(shortTitle($0.type)) \($0.count)张" }.joined(separator: " · ")
|
||||
} else if item.status == .partiallySucceeded {
|
||||
progressRow.isHidden = true
|
||||
etaLabel.isHidden = true
|
||||
outputIconView.image = UIImage(systemName: "exclamationmark.circle.fill")
|
||||
outputIconView.tintColor = AppColor.warning
|
||||
outputPill.backgroundColor = .clear
|
||||
outputLabel.textColor = UIColor(hex: 0xB56A00)
|
||||
outputLabel.text = "\(item.progress.succeeded)张成功 · \(item.progress.failed)张失败"
|
||||
} else {
|
||||
progressRow.isHidden = true
|
||||
etaLabel.isHidden = true
|
||||
let succeeded = item.status == .succeeded
|
||||
outputIconView.image = UIImage(systemName: succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
outputIconView.tintColor = succeeded ? AppColor.success : AppColor.danger
|
||||
outputPill.backgroundColor = .clear
|
||||
outputLabel.textColor = succeeded ? UIColor(hex: 0x178A55) : AppColor.danger
|
||||
outputLabel.text = succeeded ? "\(item.progress.succeeded)张结果已生成" : item.status.title
|
||||
}
|
||||
accessibilityLabel = [albumLabel.text, statusBadge.accessibilityLabel, timeLabel.text, phoneLabel.text, taskLabel.text,
|
||||
outputLabel.text, completedLabel.text, etaLabel.text]
|
||||
.compactMap { $0 }.joined(separator: ",")
|
||||
accessibilityTraits = .button
|
||||
}
|
||||
|
||||
private func configureTopInfo() {
|
||||
albumCoverImageView.accessibilityIdentifier = "aiRetouchJob.albumCover"
|
||||
albumCoverImageView.contentMode = .scaleAspectFill
|
||||
albumCoverImageView.clipsToBounds = true
|
||||
albumCoverImageView.layer.cornerRadius = 10
|
||||
albumCoverImageView.backgroundColor = AppColor.pageBackground
|
||||
albumCoverImageView.tintColor = AppColor.textTertiary
|
||||
albumLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
albumLabel.textColor = UIColor(hex: 0x0F1F3D)
|
||||
albumLabel.lineBreakMode = .byTruncatingTail
|
||||
phoneLabel.font = .systemFont(ofSize: 14)
|
||||
phoneLabel.textColor = UIColor(hex: 0x72809A)
|
||||
taskLabel.font = .systemFont(ofSize: 14)
|
||||
taskLabel.textColor = UIColor(hex: 0x72809A)
|
||||
timeIconView.tintColor = UIColor(hex: 0x7F8CA3)
|
||||
timeIconView.contentMode = .scaleAspectFit
|
||||
timeLabel.font = .systemFont(ofSize: 13)
|
||||
timeLabel.textColor = UIColor(hex: 0x7F8CA3)
|
||||
timeStack.axis = .horizontal
|
||||
timeStack.spacing = 5
|
||||
timeStack.alignment = .center
|
||||
timeStack.addArrangedSubview(timeIconView)
|
||||
timeStack.addArrangedSubview(timeLabel)
|
||||
|
||||
[albumCoverImageView, albumLabel, phoneLabel, taskLabel, timeStack, statusBadge].forEach {
|
||||
topInfoView.addSubview($0)
|
||||
}
|
||||
albumCoverImageView.snp.makeConstraints { make in
|
||||
make.leading.top.equalToSuperview()
|
||||
make.size.equalTo(68)
|
||||
}
|
||||
timeIconView.snp.makeConstraints { $0.size.equalTo(15) }
|
||||
timeStack.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview()
|
||||
}
|
||||
statusBadge.snp.makeConstraints { make in
|
||||
make.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(30)
|
||||
}
|
||||
albumLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(1)
|
||||
make.leading.equalTo(albumCoverImageView.snp.trailing).offset(12)
|
||||
make.trailing.lessThanOrEqualTo(timeStack.snp.leading).offset(-8)
|
||||
}
|
||||
phoneLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(albumLabel)
|
||||
make.top.equalTo(albumLabel.snp.bottom).offset(6)
|
||||
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
|
||||
}
|
||||
taskLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(albumLabel)
|
||||
make.top.equalTo(phoneLabel.snp.bottom).offset(5)
|
||||
make.trailing.lessThanOrEqualTo(statusBadge.snp.leading).offset(-8)
|
||||
}
|
||||
}
|
||||
|
||||
private func configurePreviewGrid() {
|
||||
previewStack.axis = .horizontal
|
||||
previewStack.spacing = 8
|
||||
previewStack.distribution = .fillEqually
|
||||
for index in 0..<3 {
|
||||
let slot = UIView()
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
imageView.layer.cornerRadius = 10
|
||||
imageView.backgroundColor = AppColor.pageBackground
|
||||
imageView.tintColor = AppColor.textTertiary
|
||||
imageView.accessibilityIdentifier = "aiRetouchJob.preview.\(index)"
|
||||
slot.addSubview(imageView)
|
||||
imageView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
previewStack.addArrangedSubview(slot)
|
||||
previewImageViews.append(imageView)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureBottomRow() {
|
||||
outputPill.layer.cornerRadius = 9
|
||||
outputPill.clipsToBounds = true
|
||||
outputIconView.contentMode = .scaleAspectFit
|
||||
outputLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
outputLabel.numberOfLines = 1
|
||||
outputLabel.adjustsFontSizeToFitWidth = true
|
||||
outputLabel.minimumScaleFactor = 0.8
|
||||
outputPill.addSubview(outputIconView)
|
||||
outputPill.addSubview(outputLabel)
|
||||
outputIconView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(10)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(17)
|
||||
}
|
||||
outputLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(outputIconView.snp.trailing).offset(6)
|
||||
make.trailing.equalToSuperview().inset(10)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
detailsLabel.text = "查看详情"
|
||||
detailsLabel.font = .systemFont(ofSize: 13)
|
||||
detailsLabel.textColor = UIColor(hex: 0x7F8CA3)
|
||||
chevronView.tintColor = UIColor(hex: 0x7F8CA3)
|
||||
chevronView.contentMode = .scaleAspectFit
|
||||
[outputPill, detailsLabel, chevronView].forEach { bottomRow.addSubview($0) }
|
||||
outputPill.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(detailsLabel.snp.leading).offset(-8)
|
||||
}
|
||||
chevronView.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.size.equalTo(14)
|
||||
}
|
||||
detailsLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(chevronView.snp.leading).offset(-6)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private func shortTitle(_ type: TravelAlbumAIJobOutputType) -> String {
|
||||
switch type {
|
||||
case .refined: "精修"
|
||||
case .atmosphere: "氛围感"
|
||||
case .cover: "封面"
|
||||
case .unknown: "其他"
|
||||
}
|
||||
}
|
||||
|
||||
private func relativeTime(_ value: String) -> String {
|
||||
guard let date = TravelAlbumAIJobDateFormatter.date(value) else { return "--" }
|
||||
let calendar = Calendar.current
|
||||
let prefix: String
|
||||
if calendar.isDateInToday(date) {
|
||||
prefix = "今天"
|
||||
} else if calendar.isDateInYesterday(date) {
|
||||
prefix = "昨天"
|
||||
} else {
|
||||
prefix = date.formatted(.dateTime.month().day())
|
||||
}
|
||||
return "\(prefix) \(date.formatted(.dateTime.hour().minute()))"
|
||||
}
|
||||
}
|
||||
|
||||
/// 带图标与底色的任务状态徽标,保证状态不只依赖颜色表达。
|
||||
private final class AIJobStatusBadgeView: UIView {
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
layer.cornerRadius = 9
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
addSubview(iconView)
|
||||
addSubview(titleLabel)
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(9)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(17)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(iconView.snp.trailing).offset(5)
|
||||
make.trailing.equalToSuperview().inset(10)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func apply(_ status: TravelAlbumAIJobStatus) {
|
||||
titleLabel.text = status.title
|
||||
let color: UIColor
|
||||
let background: UIColor
|
||||
let symbol: String
|
||||
switch status {
|
||||
case .queued:
|
||||
color = AppColor.primary
|
||||
background = AppColor.infoBackground
|
||||
symbol = "clock.arrow.circlepath"
|
||||
case .processing:
|
||||
color = AppColor.primary
|
||||
background = AppColor.infoBackground
|
||||
symbol = "arrow.triangle.2.circlepath"
|
||||
case .succeeded:
|
||||
color = AppColor.success
|
||||
background = AppColor.successBackground
|
||||
symbol = "checkmark.circle.fill"
|
||||
case .partiallySucceeded:
|
||||
color = AppColor.warning
|
||||
background = AppColor.warningBackground
|
||||
symbol = "exclamationmark.circle.fill"
|
||||
case .failed:
|
||||
color = AppColor.danger
|
||||
background = AppColor.dangerBackground
|
||||
symbol = "xmark.circle.fill"
|
||||
case .canceled, .unknown:
|
||||
color = AppColor.textSecondary
|
||||
background = AppColor.pageBackground
|
||||
symbol = "minus.circle.fill"
|
||||
}
|
||||
titleLabel.textColor = color
|
||||
iconView.tintColor = color
|
||||
iconView.image = UIImage(systemName: symbol)
|
||||
backgroundColor = background
|
||||
accessibilityLabel = status.title
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务列表空态和错误态。
|
||||
private final class AIJobEmptyView: UIView {
|
||||
private let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled"))
|
||||
private let titleLabel = UILabel()
|
||||
private let messageLabel = UILabel()
|
||||
private let retryButton = UIButton(type: .system)
|
||||
var onRetry: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
imageView.tintColor = AppColor.primary
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
titleLabel.textAlignment = .center
|
||||
messageLabel.font = .systemFont(ofSize: 14)
|
||||
messageLabel.textColor = AppColor.textSecondary
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.numberOfLines = 0
|
||||
retryButton.setTitle("重新加载", for: .normal)
|
||||
retryButton.addTarget(self, action: #selector(retry), for: .touchUpInside)
|
||||
let stack = UIStackView(arrangedSubviews: [imageView, titleLabel, messageLabel, retryButton])
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .center
|
||||
stack.spacing = 12
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.centerY.equalToSuperview().offset(-40)
|
||||
make.leading.trailing.equalToSuperview().inset(40)
|
||||
}
|
||||
imageView.snp.makeConstraints { $0.size.equalTo(52) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func apply(title: String, message: String, showsRetry: Bool) {
|
||||
titleLabel.text = title
|
||||
messageLabel.text = message
|
||||
retryButton.isHidden = !showsRetry
|
||||
}
|
||||
|
||||
@objc private func retry() { onRetry?() }
|
||||
}
|
||||
@@ -25,7 +25,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
|
||||
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
||||
private let api: any TravelAlbumServing
|
||||
private let onSubmitted: () -> Void
|
||||
private let onSubmitted: (TravelAlbumAIJobSubmission) -> Void
|
||||
|
||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
@@ -45,7 +45,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
init(
|
||||
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
||||
api: any TravelAlbumServing,
|
||||
onSubmitted: @escaping () -> Void
|
||||
onSubmitted: @escaping (TravelAlbumAIJobSubmission) -> Void
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
@@ -182,10 +182,10 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onSubmitted = { [weak self] in
|
||||
viewModel.onSubmitted = { [weak self] submission in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.dismiss(animated: true, completion: self.onSubmitted)
|
||||
self.dismiss(animated: true) { self.onSubmitted(submission) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
header.apply(
|
||||
title: category.title,
|
||||
badge: category == .cover
|
||||
? .gift
|
||||
? .requiredGift
|
||||
: (self.viewModel.isOptional(category) ? .optional : .required)
|
||||
)
|
||||
case .mode:
|
||||
@@ -434,8 +434,13 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func showPreview(for template: TravelAlbumAIRetouchTemplate) {
|
||||
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
|
||||
controller.modalPresentationStyle = .fullScreen
|
||||
guard let content = template.comparisonContent else {
|
||||
showToast("暂无对比预览")
|
||||
return
|
||||
}
|
||||
let controller = BeforeAfterComparisonViewController(
|
||||
viewModel: BeforeAfterComparisonViewModel(content: content)
|
||||
)
|
||||
present(controller, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -842,38 +847,44 @@ final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
|
||||
fileprivate enum AIRetouchTemplateSectionBadge {
|
||||
case required
|
||||
case optional
|
||||
case gift
|
||||
case requiredGift
|
||||
}
|
||||
|
||||
/// AI 修图模板分组标题,可附带选填或赠送标记。
|
||||
/// AI 修图模板分组标题,展示必选、选填或封面赠送规则。
|
||||
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let optionalLabel = UILabel()
|
||||
private let badgeContainer = UIView()
|
||||
private let badgeLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||
optionalLabel.textAlignment = .center
|
||||
optionalLabel.layer.cornerRadius = 10
|
||||
optionalLabel.clipsToBounds = true
|
||||
badgeContainer.layer.cornerRadius = 10
|
||||
badgeContainer.clipsToBounds = true
|
||||
badgeLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||
badgeLabel.textAlignment = .center
|
||||
badgeLabel.numberOfLines = 1
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(optionalLabel)
|
||||
addSubview(badgeContainer)
|
||||
badgeContainer.addSubview(badgeLabel)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
optionalLabel.snp.makeConstraints { make in
|
||||
badgeContainer.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.width.greaterThanOrEqualTo(38)
|
||||
make.height.equalTo(20)
|
||||
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
||||
}
|
||||
badgeLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(9)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -884,151 +895,29 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||
/// 更新分组标题与业务标记。
|
||||
fileprivate func apply(title: String, badge: AIRetouchTemplateSectionBadge?) {
|
||||
titleLabel.text = title
|
||||
optionalLabel.isHidden = badge == nil
|
||||
badgeContainer.isHidden = badge == nil
|
||||
switch badge {
|
||||
case .required:
|
||||
optionalLabel.text = " 必选 "
|
||||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||
badgeLabel.text = "必选"
|
||||
badgeLabel.textColor = AIRetouchTemplateStyle.danger
|
||||
badgeContainer.backgroundColor = AIRetouchTemplateStyle.danger.withAlphaComponent(0.1)
|
||||
case .optional:
|
||||
optionalLabel.text = " 选填 "
|
||||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||
case .gift:
|
||||
optionalLabel.text = " 赠送 · 不占额度 "
|
||||
optionalLabel.textColor = AIRetouchTemplateStyle.gift
|
||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.giftBackground
|
||||
badgeLabel.text = "选填"
|
||||
badgeLabel.textColor = AIRetouchTemplateStyle.primary
|
||||
badgeContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||
case .requiredGift:
|
||||
badgeLabel.text = "必选 · 赠送 · 不占额度"
|
||||
badgeLabel.textColor = AIRetouchTemplateStyle.gift
|
||||
badgeContainer.backgroundColor = AIRetouchTemplateStyle.giftBackground
|
||||
case nil:
|
||||
optionalLabel.text = nil
|
||||
badgeLabel.text = nil
|
||||
}
|
||||
let badgeText = optionalLabel.text?.trimmingCharacters(in: .whitespaces)
|
||||
let badgeText = badgeLabel.text
|
||||
accessibilityLabel = badgeText.map { "\(title),\($0)" } ?? title
|
||||
accessibilityTraits = .header
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板全屏预览,支持双指缩放查看模板细节。
|
||||
final class TravelAlbumAIRetouchTemplatePreviewViewController: UIViewController {
|
||||
private let template: TravelAlbumAIRetouchTemplate
|
||||
private let backButton = UIButton(type: .system)
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private let scrollView = UIScrollView()
|
||||
private let imageView = UIImageView()
|
||||
|
||||
/// 创建指定模板的全屏预览页。
|
||||
init(template: TravelAlbumAIRetouchTemplate) {
|
||||
self.template = template
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
loadPreview()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .black
|
||||
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreview"
|
||||
|
||||
var backConfiguration = UIButton.Configuration.filled()
|
||||
backConfiguration.image = UIImage(systemName: "chevron.left")
|
||||
backConfiguration.baseForegroundColor = .white
|
||||
backConfiguration.baseBackgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||
backConfiguration.background.cornerRadius = 24
|
||||
backButton.configuration = backConfiguration
|
||||
backButton.accessibilityLabel = "返回模板选择"
|
||||
backButton.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewBackButton"
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
|
||||
titleLabel.text = template.name
|
||||
titleLabel.textColor = .white
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
subtitleLabel.text = "双指缩放查看细节"
|
||||
subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
||||
subtitleLabel.font = .systemFont(ofSize: 14, weight: .regular)
|
||||
subtitleLabel.textAlignment = .center
|
||||
|
||||
scrollView.minimumZoomScale = 1
|
||||
scrollView.maximumZoomScale = 4
|
||||
scrollView.delegate = self
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplatePreviewScrollView"
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.clipsToBounds = true
|
||||
imageView.backgroundColor = UIColor(hex: 0x111111)
|
||||
imageView.accessibilityLabel = "\(template.name)模板预览图"
|
||||
|
||||
view.addSubview(backButton)
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(subtitleLabel)
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(imageView)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.size.equalTo(48)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||||
make.leading.greaterThanOrEqualTo(backButton.snp.trailing).offset(12)
|
||||
make.centerX.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualToSuperview().offset(-66)
|
||||
make.height.equalTo(26)
|
||||
}
|
||||
subtitleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(2)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.center.equalTo(scrollView.frameLayoutGuide)
|
||||
make.width.equalTo(scrollView.frameLayoutGuide)
|
||||
make.height.equalTo(imageView.snp.width).multipliedBy(0.75)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPreview() {
|
||||
let placeholder = UIImage(named: "ai_retouch_template_placeholder")
|
||||
guard let url = URL(string: template.previewURL), !template.previewURL.isEmpty else {
|
||||
imageView.image = placeholder
|
||||
return
|
||||
}
|
||||
imageView.kf.setImage(
|
||||
with: url,
|
||||
placeholder: placeholder
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func backTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIRetouchTemplatePreviewViewController: UIScrollViewDelegate {
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
imageView
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板页视觉常量。
|
||||
private enum AIRetouchTemplateStyle {
|
||||
static let primary = UIColor(hex: 0x1677FF)
|
||||
|
||||
@@ -31,6 +31,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
private let aiRetouchButton = UIButton(type: .system)
|
||||
private let deleteSelectedButton = UIButton(type: .system)
|
||||
private let uploadButton = UIButton(type: .system)
|
||||
private var refreshState = TravelAlbumReturnRefreshState()
|
||||
|
||||
init(
|
||||
albumId: Int,
|
||||
@@ -65,7 +66,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
navigationItem.scrollEdgeAppearance = appearance
|
||||
navigationItem.compactAppearance = appearance
|
||||
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
let moreItem = UIBarButtonItem(
|
||||
image: UIImage(systemName: "ellipsis"),
|
||||
menu: UIMenu(children: [
|
||||
UIAction(title: "删除相册", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
|
||||
@@ -73,7 +74,19 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
},
|
||||
])
|
||||
)
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "更多操作"
|
||||
moreItem.accessibilityLabel = "更多操作"
|
||||
let taskItem = UIBarButtonItem(
|
||||
title: "修图任务",
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(openAIJobList)
|
||||
)
|
||||
taskItem.accessibilityLabel = "查看AI修图任务"
|
||||
navigationItem.rightBarButtonItems = [moreItem, taskItem]
|
||||
}
|
||||
|
||||
@objc private func openAIJobList() {
|
||||
navigationController?.pushViewController(TravelAlbumAIJobListViewController(api: api), animated: true)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
@@ -250,6 +263,18 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
Task { await viewModel.refreshAll(api: api) }
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard refreshState.beginRefreshIfNeeded() else { return }
|
||||
Task {
|
||||
await viewModel.refreshAll(api: api)
|
||||
await MainActor.run {
|
||||
self.applyViewModel()
|
||||
self.refreshState.finishRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
if let album = viewModel.album {
|
||||
@@ -268,14 +293,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
sortButton.accessibilityValue = viewModel.sortOption.title
|
||||
sortButton.menu = makeSortMenu()
|
||||
|
||||
let canSelectMaterials = viewModel.selectedTab == .all
|
||||
selectButton.isHidden = false
|
||||
selectButton.isEnabled = canSelectMaterials
|
||||
selectButton.alpha = canSelectMaterials ? 1 : 0.45
|
||||
selectButton.isEnabled = true
|
||||
selectButton.alpha = 1
|
||||
selectButton.setTitle(viewModel.isSelectionMode ? "完成" : "选择", for: .normal)
|
||||
selectButton.accessibilityValue = canSelectMaterials
|
||||
? (viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭")
|
||||
: "已购照片不可删除"
|
||||
selectButton.accessibilityValue = viewModel.isSelectionMode ? "选择模式已开启" : "选择模式已关闭"
|
||||
|
||||
let selectedCount = viewModel.selectedMaterialIds.count
|
||||
let hasSelection = selectedCount > 0
|
||||
@@ -294,7 +316,10 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
if !snapshot.itemIdentifiers.isEmpty {
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: !refreshState.suppressesSnapshotAnimations
|
||||
)
|
||||
|
||||
if !viewModel.isRefreshing {
|
||||
refreshControl.endRefreshing()
|
||||
@@ -446,19 +471,24 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
materialIds: materialIds
|
||||
),
|
||||
api: api,
|
||||
onSubmitted: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.viewModel.completeAIRetouchSubmission()
|
||||
self.showToast("AI修图任务已提交")
|
||||
}
|
||||
onSubmitted: { [weak self] _ in self?.handleAIRetouchSubmitted() }
|
||||
)
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
private func handleAIRetouchSubmitted() {
|
||||
showToast("AI修图任务已提交,完成后将通过消息通知")
|
||||
Task { await viewModel.refreshAfterAIRetouchSubmission(api: api) }
|
||||
}
|
||||
|
||||
@objc private func deleteSelectedTapped() {
|
||||
let count = viewModel.selectedMaterialIds.count
|
||||
guard count > 0 else { return }
|
||||
let alert = UIAlertController(title: "删除素材", message: "确定删除选中的 \(count) 张素材吗?", preferredStyle: .alert)
|
||||
let alert = UIAlertController(
|
||||
title: "删除素材",
|
||||
message: viewModel.deleteSelectedConfirmationMessage,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
@@ -469,6 +499,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
|
||||
@objc private func uploadTapped() {
|
||||
let album = viewModel.album
|
||||
refreshState.markRefreshNeeded()
|
||||
let controller = WiredCameraTransferViewController(
|
||||
viewModel: WiredCameraTransferViewModel(
|
||||
albumId: album?.id ?? viewModel.albumId,
|
||||
@@ -505,7 +536,8 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
},
|
||||
onProjectDeleted: { materialId in
|
||||
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
||||
}
|
||||
},
|
||||
onAIRetouchSubmitted: { [weak self] in self?.handleAIRetouchSubmitted() }
|
||||
)
|
||||
present(controller, animated: true)
|
||||
}
|
||||
@@ -663,14 +695,16 @@ private final class TravelAlbumInfoCard: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
|
||||
/// 旅拍相册素材网格单元,独立展示购买、修图和选择状态。
|
||||
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||||
|
||||
private let imageView = UIImageView()
|
||||
private let checkImageView = UIImageView()
|
||||
private let badgeView = UIView()
|
||||
private let badgeLabel = UILabel()
|
||||
private let purchaseBadgeView = UIView()
|
||||
private let purchaseBadgeLabel = UILabel()
|
||||
private let aiRetouchBadgeView = UIView()
|
||||
private let aiRetouchBadgeLabel = UILabel()
|
||||
private let nameLabel = UILabel()
|
||||
private let sizeLabel = UILabel()
|
||||
|
||||
@@ -684,18 +718,18 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
checkImageView.layer.cornerRadius = 11
|
||||
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
||||
badgeView.layer.cornerRadius = 5
|
||||
badgeView.clipsToBounds = true
|
||||
badgeView.isHidden = true
|
||||
badgeView.isAccessibilityElement = false
|
||||
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
|
||||
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
|
||||
badgeLabel.textColor = .white
|
||||
badgeLabel.textAlignment = .center
|
||||
badgeLabel.isAccessibilityElement = false
|
||||
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
|
||||
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
configureBadge(
|
||||
purchaseBadgeView,
|
||||
label: purchaseBadgeLabel,
|
||||
viewIdentifier: "travelAlbum.materialPurchaseBadge",
|
||||
labelIdentifier: "travelAlbum.materialPurchaseBadgeLabel"
|
||||
)
|
||||
configureBadge(
|
||||
aiRetouchBadgeView,
|
||||
label: aiRetouchBadgeLabel,
|
||||
viewIdentifier: "travelAlbum.materialAIRetouchBadge",
|
||||
labelIdentifier: "travelAlbum.materialAIRetouchBadgeLabel"
|
||||
)
|
||||
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||||
@@ -703,8 +737,8 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||||
|
||||
contentView.addSubview(imageView)
|
||||
imageView.addSubview(badgeView)
|
||||
badgeView.addSubview(badgeLabel)
|
||||
imageView.addSubview(purchaseBadgeView)
|
||||
imageView.addSubview(aiRetouchBadgeView)
|
||||
imageView.addSubview(checkImageView)
|
||||
contentView.addSubview(nameLabel)
|
||||
contentView.addSubview(sizeLabel)
|
||||
@@ -716,14 +750,13 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
make.top.trailing.equalToSuperview().inset(6)
|
||||
make.size.equalTo(22)
|
||||
}
|
||||
badgeView.snp.makeConstraints { make in
|
||||
aiRetouchBadgeView.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().inset(6)
|
||||
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
||||
}
|
||||
badgeLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(
|
||||
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
||||
)
|
||||
purchaseBadgeView.snp.makeConstraints { make in
|
||||
make.leading.bottom.equalToSuperview().inset(6)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(6)
|
||||
}
|
||||
nameLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(imageView.snp.bottom).offset(6)
|
||||
@@ -753,14 +786,54 @@ final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
checkImageView.isHidden = !selectionMode
|
||||
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||||
let badge = material.badgePresentation
|
||||
badgeView.isHidden = badge == nil
|
||||
badgeLabel.text = badge?.text
|
||||
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
||||
let purchaseBadge = material.purchaseBadgePresentation
|
||||
let aiRetouchBadge = material.aiRetouchBadgePresentation
|
||||
applyBadge(purchaseBadge, to: purchaseBadgeView, label: purchaseBadgeLabel)
|
||||
applyBadge(aiRetouchBadge, to: aiRetouchBadgeView, label: aiRetouchBadgeLabel)
|
||||
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||||
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
|
||||
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
|
||||
let purchaseAccessibilityText = purchaseBadge.map { ",购买状态:\($0.text)" } ?? ""
|
||||
let aiRetouchAccessibilityText = aiRetouchBadge.map { ",修图状态:\($0.text)" } ?? ""
|
||||
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
|
||||
+ purchaseAccessibilityText
|
||||
+ aiRetouchAccessibilityText
|
||||
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
||||
}
|
||||
|
||||
private func configureBadge(
|
||||
_ badgeView: UIView,
|
||||
label: UILabel,
|
||||
viewIdentifier: String,
|
||||
labelIdentifier: String
|
||||
) {
|
||||
badgeView.layer.cornerRadius = 5
|
||||
badgeView.clipsToBounds = true
|
||||
badgeView.isHidden = true
|
||||
badgeView.isAccessibilityElement = false
|
||||
badgeView.accessibilityIdentifier = viewIdentifier
|
||||
label.font = .systemFont(ofSize: 10, weight: .semibold)
|
||||
label.textColor = .white
|
||||
label.textAlignment = .center
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
label.isAccessibilityElement = false
|
||||
label.accessibilityIdentifier = labelIdentifier
|
||||
label.setContentHuggingPriority(.required, for: .horizontal)
|
||||
label.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
badgeView.addSubview(label)
|
||||
label.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(
|
||||
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyBadge(
|
||||
_ presentation: TravelAlbumMaterialBadgePresentation?,
|
||||
to badgeView: UIView,
|
||||
label: UILabel
|
||||
) {
|
||||
badgeView.isHidden = presentation == nil
|
||||
label.text = presentation?.text
|
||||
badgeView.backgroundColor = presentation.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,27 @@ import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 相册页面的一次性返回刷新状态,避免初次展示或重复出现时多发列表请求。
|
||||
struct TravelAlbumReturnRefreshState {
|
||||
private var needsRefresh = false
|
||||
private(set) var suppressesSnapshotAnimations = false
|
||||
|
||||
mutating func markRefreshNeeded() {
|
||||
needsRefresh = true
|
||||
}
|
||||
|
||||
mutating func beginRefreshIfNeeded() -> Bool {
|
||||
guard needsRefresh else { return false }
|
||||
needsRefresh = false
|
||||
suppressesSnapshotAnimations = true
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func finishRefresh() {
|
||||
suppressesSnapshotAnimations = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 新增相册入口页,对齐 Android `TravelAlbumEntryScreen`。
|
||||
final class TravelAlbumEntryViewController: BaseViewController {
|
||||
private let viewModel = TravelAlbumEntryViewModel()
|
||||
@@ -15,8 +36,10 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
private let heroCard = TravelAlbumHeroCard()
|
||||
private let titleLabel = UILabel()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let refreshControl = UIRefreshControl()
|
||||
private let emptyView = TravelAlbumEmptyView()
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
|
||||
private var refreshState = TravelAlbumReturnRefreshState()
|
||||
|
||||
init(api: (any TravelAlbumServing)? = nil) {
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
@@ -43,6 +66,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
tableView.separatorStyle = .none
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 148
|
||||
tableView.alwaysBounceVertical = true
|
||||
tableView.refreshControl = refreshControl
|
||||
tableView.delegate = self
|
||||
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
|
||||
|
||||
@@ -62,6 +87,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyView)
|
||||
emptyView.isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
@@ -86,6 +112,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
|
||||
override func bindActions() {
|
||||
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
|
||||
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
@@ -99,24 +126,49 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
Task { await viewModel.loadAlbums(api: api) }
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard refreshState.beginRefreshIfNeeded() else { return }
|
||||
Task {
|
||||
await viewModel.loadAlbums(api: api)
|
||||
await MainActor.run {
|
||||
self.applyViewModel()
|
||||
self.refreshState.finishRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
heroCard.isLoading = viewModel.isCreating
|
||||
titleLabel.text = "我的任务(\(viewModel.albumTotal))"
|
||||
titleLabel.isHidden = viewModel.albums.isEmpty
|
||||
tableView.isHidden = viewModel.albums.isEmpty
|
||||
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading
|
||||
emptyView.isHidden = !viewModel.albums.isEmpty
|
||||
|| (viewModel.isLoading && !refreshControl.isRefreshing)
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.albums)
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
if viewModel.isLoading && viewModel.albums.isEmpty {
|
||||
dataSource.apply(
|
||||
snapshot,
|
||||
animatingDifferences: !refreshState.suppressesSnapshotAnimations
|
||||
)
|
||||
if viewModel.isLoading && viewModel.albums.isEmpty && !refreshControl.isRefreshing {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
Task {
|
||||
await viewModel.refreshAlbums(api: api)
|
||||
await MainActor.run {
|
||||
self.refreshControl.endRefreshing()
|
||||
self.applyViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func createTapped() {
|
||||
Task {
|
||||
await viewModel.openCreateSheet(api: api)
|
||||
@@ -162,6 +214,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func pushWiredTransfer(album: TravelAlbum) {
|
||||
refreshState.markRefreshNeeded()
|
||||
let controller = WiredCameraTransferViewController(
|
||||
viewModel: WiredCameraTransferViewModel(
|
||||
albumId: album.id,
|
||||
@@ -177,7 +230,11 @@ extension TravelAlbumEntryViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let album = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
navigationController?.pushViewController(TravelAlbumDetailViewController(albumId: album.id), animated: true)
|
||||
refreshState.markRefreshNeeded()
|
||||
navigationController?.pushViewController(
|
||||
TravelAlbumDetailViewController(albumId: album.id, api: api),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
private let loadMore: TravelAlbumPreviewLoadMore?
|
||||
private let reload: TravelAlbumPreviewReload?
|
||||
private let onProjectDeleted: ((Int) -> Void)?
|
||||
private let onAIRetouchSubmitted: (() -> Void)?
|
||||
private let allowsActions: Bool
|
||||
private var nodes: [TravelAlbumPreviewNode] = []
|
||||
private var currentNodeIndex = 0
|
||||
private var dragStartIndex = 0
|
||||
@@ -41,16 +43,19 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
private let closeButton = UIButton(type: .system)
|
||||
private let titleLabel = UILabel()
|
||||
private let sizeLabel = UILabel()
|
||||
private let counterContainer = UIView()
|
||||
private let counterLabel = UILabel()
|
||||
private let bottomChrome = UIView()
|
||||
private let divider = UIView()
|
||||
private let variantControls = UIView()
|
||||
private let variantSegmentedControl = UISegmentedControl()
|
||||
private let highResolutionButton = UIButton(type: .system)
|
||||
private let comparisonButton = UIButton(type: .system)
|
||||
private let actionStack = UIStackView()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
private let refreshButton = UIButton(type: .system)
|
||||
private var variantControlsHeightConstraint: Constraint?
|
||||
private var variantSegmentedControlWidthConstraint: Constraint?
|
||||
private var didConfigureVariantControls = false
|
||||
private var showsVariantControls = false
|
||||
private var variantControlsAnimationGeneration = 0
|
||||
@@ -60,6 +65,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
projects: [TravelAlbumPreviewProject],
|
||||
totalCount: Int,
|
||||
startProjectIndex: Int,
|
||||
startKind: TravelAlbumPreviewAssetKind = .original,
|
||||
allowsActions: Bool = true,
|
||||
configuration: TravelAlbumPreviewConfiguration = .init(),
|
||||
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
||||
albumId: Int = 0,
|
||||
@@ -67,7 +74,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
aiRetouchAPI: (any TravelAlbumServing)? = nil,
|
||||
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
||||
reload: TravelAlbumPreviewReload? = nil,
|
||||
onProjectDeleted: ((Int) -> Void)? = nil
|
||||
onProjectDeleted: ((Int) -> Void)? = nil,
|
||||
onAIRetouchSubmitted: (() -> Void)? = nil
|
||||
) {
|
||||
self.projects = Self.deduplicated(projects)
|
||||
self.totalCount = max(totalCount, projects.count)
|
||||
@@ -79,9 +87,19 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
self.loadMore = loadMore
|
||||
self.reload = reload
|
||||
self.onProjectDeleted = onProjectDeleted
|
||||
self.onAIRetouchSubmitted = onAIRetouchSubmitted
|
||||
self.allowsActions = allowsActions
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original)
|
||||
let resolvedProjectIndex = max(0, min(startProjectIndex, self.projects.count - 1))
|
||||
let initialProject = self.projects.indices.contains(resolvedProjectIndex)
|
||||
? self.projects[resolvedProjectIndex]
|
||||
: nil
|
||||
let resolvedStartKind = initialProject?.asset(for: startKind) == nil
|
||||
? initialProject?.orderedAssets.first?.kind ?? .original
|
||||
: startKind
|
||||
selectedKind = resolvedStartKind
|
||||
rebuildNodes(keepingProjectIndex: resolvedProjectIndex, kind: resolvedStartKind)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -136,7 +154,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
divider.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||
|
||||
var backConfiguration = UIButton.Configuration.plain()
|
||||
backConfiguration.image = UIImage(systemName: "chevron.backward")
|
||||
backConfiguration.image = UIImage(systemName: "chevron.down")
|
||||
backConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
|
||||
pointSize: 21,
|
||||
weight: .semibold
|
||||
@@ -155,23 +173,26 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
sizeLabel.textColor = UIColor.white.withAlphaComponent(0.52)
|
||||
sizeLabel.font = .systemFont(ofSize: 13, weight: .regular)
|
||||
sizeLabel.textAlignment = .center
|
||||
sizeLabel.accessibilityIdentifier = "travelAlbum.previewFileSizeLabel"
|
||||
|
||||
counterLabel.textColor = UIColor.white.withAlphaComponent(0.82)
|
||||
counterLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium)
|
||||
counterLabel.textAlignment = .center
|
||||
counterLabel.backgroundColor = UIColor(white: 0.11, alpha: 1)
|
||||
counterLabel.layer.cornerRadius = 17
|
||||
counterLabel.clipsToBounds = true
|
||||
counterLabel.numberOfLines = 1
|
||||
counterLabel.accessibilityIdentifier = "travelAlbum.previewCounterLabel"
|
||||
counterLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
counterLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
counterContainer.backgroundColor = UIColor(white: 0.11, alpha: 1)
|
||||
counterContainer.layer.cornerRadius = 17
|
||||
counterContainer.clipsToBounds = true
|
||||
|
||||
variantSegmentedControl.selectedSegmentTintColor = UIColor(white: 0.22, alpha: 1)
|
||||
variantSegmentedControl.backgroundColor = UIColor(white: 0.065, alpha: 1)
|
||||
variantSegmentedControl.selectedSegmentTintColor = UIColor.white.withAlphaComponent(0.2)
|
||||
variantSegmentedControl.setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor.white.withAlphaComponent(0.62), .font: UIFont.systemFont(ofSize: 15)],
|
||||
[.foregroundColor: UIColor.white.withAlphaComponent(0.7)],
|
||||
for: .normal
|
||||
)
|
||||
variantSegmentedControl.setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor.white, .font: UIFont.systemFont(ofSize: 15, weight: .semibold)],
|
||||
[.foregroundColor: UIColor.white],
|
||||
for: .selected
|
||||
)
|
||||
variantSegmentedControl.accessibilityIdentifier = "travelAlbum.previewVariantSegmentedControl"
|
||||
@@ -189,7 +210,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
)
|
||||
actionStack.accessibilityIdentifier = "travelAlbum.previewActionStack"
|
||||
configureHighResolutionButton()
|
||||
configureComparisonButton()
|
||||
configureActions()
|
||||
actionStack.isHidden = !allowsActions
|
||||
|
||||
let titleStack = UIStackView(arrangedSubviews: [titleLabel, sizeLabel])
|
||||
titleStack.axis = .vertical
|
||||
@@ -200,13 +223,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
view.addSubview(topChrome)
|
||||
topChrome.addSubview(closeButton)
|
||||
topChrome.addSubview(titleStack)
|
||||
topChrome.addSubview(counterLabel)
|
||||
topChrome.addSubview(counterContainer)
|
||||
counterContainer.addSubview(counterLabel)
|
||||
view.addSubview(bottomChrome)
|
||||
bottomChrome.addSubview(divider)
|
||||
bottomChrome.addSubview(variantControls)
|
||||
variantControls.addSubview(variantSegmentedControl)
|
||||
variantControls.addSubview(highResolutionButton)
|
||||
bottomChrome.addSubview(actionStack)
|
||||
view.addSubview(comparisonButton)
|
||||
|
||||
topChrome.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
@@ -217,16 +242,20 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
make.bottom.equalToSuperview().offset(-14)
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
counterLabel.snp.makeConstraints { make in
|
||||
counterContainer.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.centerY.equalTo(closeButton)
|
||||
make.width.greaterThanOrEqualTo(48)
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
counterLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(12)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
titleStack.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(closeButton)
|
||||
make.leading.greaterThanOrEqualTo(closeButton.snp.trailing).offset(8)
|
||||
make.trailing.lessThanOrEqualTo(counterLabel.snp.leading).offset(-8)
|
||||
make.trailing.lessThanOrEqualTo(counterContainer.snp.leading).offset(-8)
|
||||
make.centerX.equalToSuperview()
|
||||
make.width.lessThanOrEqualTo(220)
|
||||
}
|
||||
@@ -249,8 +278,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
variantSegmentedControl.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(20)
|
||||
make.centerY.equalToSuperview()
|
||||
make.height.equalTo(40)
|
||||
make.trailing.equalTo(highResolutionButton.snp.leading).offset(-12)
|
||||
variantSegmentedControlWidthConstraint = make.width.equalTo(124).constraint
|
||||
make.height.equalTo(32)
|
||||
make.trailing.lessThanOrEqualTo(highResolutionButton.snp.leading).offset(-12)
|
||||
}
|
||||
highResolutionButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(18)
|
||||
@@ -264,6 +294,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
make.height.equalTo(70)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
comparisonButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(bottomChrome.snp.top).offset(-12)
|
||||
make.size.equalTo(48)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureHighResolutionButton() {
|
||||
@@ -283,6 +318,27 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
highResolutionButton.addTarget(self, action: #selector(highResolutionTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func configureComparisonButton() {
|
||||
var configuration = UIButton.Configuration.filled()
|
||||
configuration.image = UIImage(named: "travel_album_before_after")?.withRenderingMode(.alwaysTemplate)
|
||||
configuration.contentInsets = .zero
|
||||
configuration.baseForegroundColor = .white
|
||||
configuration.baseBackgroundColor = UIColor(hex: 0x111827).withAlphaComponent(0.94)
|
||||
configuration.background.cornerRadius = 24
|
||||
configuration.background.strokeColor = UIColor(hex: 0x263244)
|
||||
configuration.background.strokeWidth = 1
|
||||
comparisonButton.configuration = configuration
|
||||
comparisonButton.accessibilityLabel = "前后对比"
|
||||
comparisonButton.accessibilityHint = "对比当前效果图与原图"
|
||||
comparisonButton.accessibilityIdentifier = "travelAlbum.previewComparisonButton"
|
||||
comparisonButton.layer.shadowColor = UIColor.black.cgColor
|
||||
comparisonButton.layer.shadowOpacity = 0.28
|
||||
comparisonButton.layer.shadowRadius = 8
|
||||
comparisonButton.layer.shadowOffset = CGSize(width: 0, height: 3)
|
||||
comparisonButton.isHidden = true
|
||||
comparisonButton.addTarget(self, action: #selector(comparisonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func configureActions() {
|
||||
let aiButton = makeActionButton(
|
||||
title: "AI修图",
|
||||
@@ -376,15 +432,29 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
guard let node = currentNode else { return }
|
||||
let asset = currentAsset
|
||||
titleLabel.text = asset?.fileName.isEmpty == false ? asset?.fileName : "未命名照片"
|
||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(asset?.fileSize ?? 0)
|
||||
let fileSize = asset?.fileSize ?? 0
|
||||
sizeLabel.text = fileSize > 0 ? TravelAlbumDisplayFormatter.fileSizeText(fileSize) : nil
|
||||
sizeLabel.isHidden = fileSize <= 0
|
||||
counterLabel.text = "\(node.projectIndex + 1)/\(max(totalCount, projects.count))"
|
||||
counterLabel.accessibilityLabel = "第 \(node.projectIndex + 1) 张,共 \(max(totalCount, projects.count)) 张"
|
||||
highResolutionButton.isEnabled = asset != nil
|
||||
highResolutionButton.alpha = asset == nil ? 0.45 : 1
|
||||
rebuildTabs()
|
||||
updateComparisonButton()
|
||||
loadMoreIfNeeded(projectIndex: node.projectIndex)
|
||||
}
|
||||
|
||||
private func updateComparisonButton() {
|
||||
let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
|
||||
let shouldShow = chromeVisible && hasComparison
|
||||
comparisonButton.isHidden = !hasComparison
|
||||
comparisonButton.isUserInteractionEnabled = shouldShow
|
||||
comparisonButton.alpha = shouldShow ? 1 : 0
|
||||
comparisonButton.transform = shouldShow
|
||||
? .identity
|
||||
: CGAffineTransform(translationX: 0, y: 10)
|
||||
}
|
||||
|
||||
private func rebuildTabs() {
|
||||
while variantSegmentedControl.numberOfSegments > 0 {
|
||||
variantSegmentedControl.removeSegment(at: 0, animated: false)
|
||||
@@ -397,6 +467,13 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
for (index, asset) in project.orderedAssets.enumerated() {
|
||||
variantSegmentedControl.insertSegment(withTitle: asset.kind.title, at: index, animated: false)
|
||||
}
|
||||
let controlWidth: CGFloat
|
||||
switch project.orderedAssets.count {
|
||||
case 4: controlWidth = 220
|
||||
case 3: controlWidth = 180
|
||||
default: controlWidth = 124
|
||||
}
|
||||
variantSegmentedControlWidthConstraint?.update(offset: controlWidth)
|
||||
variantSegmentedControl.selectedSegmentIndex = project.orderedAssets.firstIndex {
|
||||
$0.kind == activeKind
|
||||
} ?? 0
|
||||
@@ -564,6 +641,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
|
||||
private func toggleChrome() {
|
||||
chromeVisible.toggle()
|
||||
let hasComparison = currentProject?.comparisonContent(for: currentAsset?.kind ?? .original) != nil
|
||||
if chromeVisible && hasComparison {
|
||||
comparisonButton.isHidden = false
|
||||
}
|
||||
comparisonButton.isUserInteractionEnabled = chromeVisible && hasComparison
|
||||
let reduceMotion = UIAccessibility.isReduceMotionEnabled
|
||||
let animations = {
|
||||
self.topChrome.alpha = self.chromeVisible ? 1 : 0
|
||||
@@ -574,12 +656,20 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
self.bottomChrome.transform = self.chromeVisible
|
||||
? .identity
|
||||
: CGAffineTransform(translationX: 0, y: self.bottomChrome.bounds.height)
|
||||
self.comparisonButton.alpha = self.chromeVisible && hasComparison ? 1 : 0
|
||||
self.comparisonButton.transform = self.chromeVisible && hasComparison
|
||||
? .identity
|
||||
: CGAffineTransform(translationX: 0, y: 10)
|
||||
}
|
||||
UIView.animate(
|
||||
withDuration: reduceMotion ? 0.12 : 0.22,
|
||||
delay: 0,
|
||||
options: reduceMotion ? [.curveEaseOut] : [.curveEaseOut, .beginFromCurrentState],
|
||||
animations: animations
|
||||
animations: animations,
|
||||
completion: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.comparisonButton.isHidden = !self.chromeVisible || !hasComparison
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -615,6 +705,17 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
toggleChrome()
|
||||
}
|
||||
|
||||
@objc private func comparisonTapped() {
|
||||
guard presentedViewController == nil,
|
||||
let project = currentProject,
|
||||
let content = project.comparisonContent(for: currentAsset?.kind ?? .original)
|
||||
else { return }
|
||||
let controller = BeforeAfterComparisonViewController(
|
||||
viewModel: BeforeAfterComparisonViewModel(content: content)
|
||||
)
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
@objc private func aiTapped() {
|
||||
guard presentedViewController == nil else { return }
|
||||
guard let project = currentProject else { return }
|
||||
@@ -639,10 +740,9 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
workflow: workflow
|
||||
),
|
||||
api: aiRetouchAPI,
|
||||
onSubmitted: { [weak self] in
|
||||
onSubmitted: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.showPreviewToast("AI修图任务已提交")
|
||||
self.reloadProjects(showGlobalLoading: false, forceRefreshImage: false)
|
||||
self.dismiss(animated: true) { self.onAIRetouchSubmitted?() }
|
||||
}
|
||||
)
|
||||
present(controller, animated: true)
|
||||
@@ -893,6 +993,56 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
|
||||
}
|
||||
}
|
||||
|
||||
/// 深色图片预览中使用的高对比度延迟加载指示器。
|
||||
@MainActor
|
||||
private final class TravelAlbumPreviewLoadingIndicator: Indicator {
|
||||
private let containerView = UIView()
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private var revealTask: Task<Void, Never>?
|
||||
|
||||
var view: IndicatorView { containerView }
|
||||
|
||||
init() {
|
||||
containerView.backgroundColor = UIColor(red: 39 / 255, green: 39 / 255, blue: 42 / 255, alpha: 0.94)
|
||||
containerView.layer.cornerRadius = 22
|
||||
containerView.isAccessibilityElement = true
|
||||
containerView.accessibilityIdentifier = "travelAlbum.previewLoadingIndicator"
|
||||
containerView.accessibilityLabel = "图片加载中"
|
||||
activityIndicator.color = UIColor.white.withAlphaComponent(0.92)
|
||||
activityIndicator.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
|
||||
containerView.addSubview(activityIndicator)
|
||||
activityIndicator.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||
}
|
||||
|
||||
func startAnimatingView() {
|
||||
revealTask?.cancel()
|
||||
containerView.alpha = 0
|
||||
containerView.isHidden = true
|
||||
revealTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
activityIndicator.startAnimating()
|
||||
containerView.isHidden = false
|
||||
UIView.animate(
|
||||
withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.12,
|
||||
animations: { self.containerView.alpha = 1 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func stopAnimatingView() {
|
||||
revealTask?.cancel()
|
||||
revealTask = nil
|
||||
activityIndicator.stopAnimating()
|
||||
containerView.alpha = 0
|
||||
containerView.isHidden = true
|
||||
}
|
||||
|
||||
func sizeStrategy(in imageView: KFCrossPlatformImageView) -> IndicatorSizeStrategy {
|
||||
.size(CGSize(width: 44, height: 44))
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
||||
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
||||
@@ -965,7 +1115,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.backgroundColor = .black
|
||||
imageView.kf.indicatorType = .activity
|
||||
imageView.kf.indicatorType = .custom(indicator: TravelAlbumPreviewLoadingIndicator())
|
||||
imageView.accessibilityIdentifier = "travelAlbum.previewImageView"
|
||||
|
||||
retryButton.setTitle("图片加载失败,点击重试", for: .normal)
|
||||
|
||||
@@ -19,8 +19,8 @@ final class MessageCenterAPITests: XCTestCase {
|
||||
"id": 21,
|
||||
"receiver_id": 9,
|
||||
"receiver_type": "staff",
|
||||
"type": 2,
|
||||
"type_name": "退款成功通知",
|
||||
"type": 14,
|
||||
"type_name": "AI修图任务通知",
|
||||
"title": "unused",
|
||||
"content": "退款已完成",
|
||||
"push_at": "2026-07-09 10:00:00",
|
||||
@@ -28,7 +28,7 @@ final class MessageCenterAPITests: XCTestCase {
|
||||
"read_at": "",
|
||||
"push_channel": 1,
|
||||
"push_channel_name": "站内信",
|
||||
"extra_data": {"order_no":"NO123","amount":12.5},
|
||||
"extra_data": {"ai_retouch_batch_id":"59","user_equity_travel_id":17},
|
||||
"created_at": "2026-07-09 10:00:00",
|
||||
"updated_at": "2026-07-09 10:01:00"
|
||||
}]
|
||||
@@ -50,7 +50,7 @@ final class MessageCenterAPITests: XCTestCase {
|
||||
XCTAssertEqual(response.lastId, 18)
|
||||
XCTAssertEqual(response.items.first?.id, 21)
|
||||
XCTAssertEqual(response.items.first?.isRead, false)
|
||||
XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123"))
|
||||
XCTAssertEqual(response.items.first?.aiRetouchBatchId, 59)
|
||||
}
|
||||
|
||||
func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws {
|
||||
|
||||
@@ -178,6 +178,44 @@ final class MessageCenterViewModelTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(failureMessage, "删除失败: server down")
|
||||
}
|
||||
|
||||
func testAIRetouchMessageExposesTaskActionAndParsesDirectBatchId() {
|
||||
let message = makeMessage(
|
||||
id: 14,
|
||||
type: 14,
|
||||
extraData: ["ai_retouch_batch_id": .number(59)]
|
||||
)
|
||||
let viewModel = MessageDetailViewModel(message: message)
|
||||
|
||||
XCTAssertTrue(viewModel.showsAIRetouchTaskAction)
|
||||
XCTAssertEqual(viewModel.aiRetouchBatchId, 59)
|
||||
}
|
||||
|
||||
func testAIRetouchMessageParsesNestedStringBatchIdAndFallsBackWhenInvalid() {
|
||||
let nested = makeMessage(
|
||||
id: 15,
|
||||
type: 14,
|
||||
extraData: ["data": .object(["ai_retouch_batch_id": .string(" 61 ")])]
|
||||
)
|
||||
let wrapped = makeMessage(
|
||||
id: 17,
|
||||
type: 14,
|
||||
extraData: ["data": .string(#"{"ai_retouch_batch_id":63}"#)]
|
||||
)
|
||||
let missing = makeMessage(id: 16, type: 14, extraData: [:])
|
||||
let manualRetouch = makeMessage(
|
||||
id: 10,
|
||||
type: 10,
|
||||
extraData: ["ai_retouch_batch_id": .number(59)]
|
||||
)
|
||||
|
||||
XCTAssertEqual(MessageDetailViewModel(message: nested).aiRetouchBatchId, 61)
|
||||
XCTAssertEqual(MessageDetailViewModel(message: wrapped).aiRetouchBatchId, 63)
|
||||
XCTAssertTrue(MessageDetailViewModel(message: missing).showsAIRetouchTaskAction)
|
||||
XCTAssertNil(MessageDetailViewModel(message: missing).aiRetouchBatchId)
|
||||
XCTAssertFalse(MessageDetailViewModel(message: manualRetouch).showsAIRetouchTaskAction)
|
||||
XCTAssertNil(MessageDetailViewModel(message: manualRetouch).aiRetouchBatchId)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -226,12 +264,17 @@ private struct TestError: LocalizedError {
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem {
|
||||
private func makeMessage(
|
||||
id: Int,
|
||||
isRead: Bool = false,
|
||||
type: Int = 1,
|
||||
extraData: [String: MessageJSONValue]? = nil
|
||||
) -> MessageItem {
|
||||
MessageItem(
|
||||
id: id,
|
||||
receiverId: 1,
|
||||
receiverType: "staff",
|
||||
type: 1,
|
||||
type: type,
|
||||
typeName: "系统通知",
|
||||
title: "系统通知",
|
||||
content: "消息内容",
|
||||
@@ -240,6 +283,7 @@ private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem {
|
||||
readAt: "",
|
||||
pushChannel: 1,
|
||||
pushChannelName: "站内信",
|
||||
extraData: extraData,
|
||||
createdAt: "2026-07-09 10:00:00",
|
||||
updatedAt: "2026-07-09 10:00:00"
|
||||
)
|
||||
|
||||
@@ -63,6 +63,34 @@ final class PushNotificationTests: XCTestCase {
|
||||
XCTAssertEqual(nested.destination, .paymentDetails)
|
||||
}
|
||||
|
||||
func testAIRetouchPushRoutesToDetailAndFallsBackToList() {
|
||||
XCTAssertEqual(
|
||||
PushPayload(userInfo: [
|
||||
"type": 14,
|
||||
"data": ["ai_retouch_batch_id": 91, "status": "succeeded"],
|
||||
]).destination,
|
||||
.aiRetouchTaskDetail(batchId: 91)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
PushPayload(userInfo: ["type": 14, "data": [:]]).destination,
|
||||
.aiRetouchTaskList
|
||||
)
|
||||
XCTAssertEqual(
|
||||
PushPayload(userInfo: ["type": 14, "data": ["ai_retouch_batch_id": "0"]]).destination,
|
||||
.aiRetouchTaskList
|
||||
)
|
||||
XCTAssertEqual(PushPayload(userInfo: ["type": 10]).destination, .messageCenter)
|
||||
}
|
||||
|
||||
func testAIRetouchPushReadsEncodedDataAndVendorWrapper() {
|
||||
let payload = PushPayload(userInfo: [
|
||||
"n_extras": #"{"type":14,"data":"{\"ai_retouch_batch_id\":92}"}"#,
|
||||
])
|
||||
|
||||
XCTAssertEqual(payload.destination, .aiRetouchTaskDetail(batchId: 92))
|
||||
XCTAssertEqual(payload.normalizedValues["ai_retouch_batch_id"], "92")
|
||||
}
|
||||
|
||||
func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws {
|
||||
let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())])
|
||||
let api = PushAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
@@ -25,7 +25,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(viewModel.shouldShowInitialTips)
|
||||
XCTAssertEqual(
|
||||
viewModel.initialTipsText,
|
||||
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。"
|
||||
"Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
|
||||
)
|
||||
XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover])
|
||||
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
||||
@@ -44,6 +44,14 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
)
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
XCTAssertEqual(
|
||||
viewModel.initialTipsText,
|
||||
"Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
|
||||
)
|
||||
|
||||
viewModel.toggleTemplate(id: 11, category: .refined)
|
||||
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
||||
|
||||
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21)
|
||||
|
||||
@@ -94,7 +102,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testReretouchWorkflowShowsAndSubmitsOnlyRequiredCategories() async {
|
||||
func testReretouchWorkflowAllowsAtmosphereOnlyFromOriginalTab() async {
|
||||
let api = makeAPI()
|
||||
let refined = TravelAlbumAIRetouchTemplateViewModel(
|
||||
scenicId: 18,
|
||||
@@ -125,8 +133,21 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
)
|
||||
await all.loadTemplates(api: api)
|
||||
XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere])
|
||||
XCTAssertNil(all.selectedRefinedTemplateId)
|
||||
XCTAssertNil(all.selectedAtmosphereTemplateId)
|
||||
XCTAssertTrue(all.isOptional(.refined))
|
||||
XCTAssertTrue(all.isOptional(.atmosphere))
|
||||
XCTAssertEqual(all.requiredQuota, 0)
|
||||
XCTAssertFalse(all.canSubmit)
|
||||
XCTAssertEqual(all.validationMessage, "请至少选择一个修图模板")
|
||||
|
||||
all.toggleTemplate(id: 11, category: .refined)
|
||||
XCTAssertEqual(all.selectedRefinedTemplateId, 11)
|
||||
XCTAssertEqual(all.selectedAtmosphereTemplateId, 21)
|
||||
all.toggleTemplate(id: 11, category: .refined)
|
||||
XCTAssertNil(all.selectedRefinedTemplateId)
|
||||
all.toggleTemplate(id: 21, category: .atmosphere)
|
||||
XCTAssertEqual(all.requiredQuota, 1)
|
||||
XCTAssertTrue(all.canSubmit)
|
||||
await all.submit(api: api)
|
||||
|
||||
XCTAssertEqual(api.aiReretouchRequests, [
|
||||
@@ -148,7 +169,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
id: 9,
|
||||
aiRetouchBatchId: 90,
|
||||
type: .all,
|
||||
refinedTemplateId: 11,
|
||||
refinedTemplateId: nil,
|
||||
atmosphereTemplateId: 21
|
||||
),
|
||||
])
|
||||
@@ -172,12 +193,13 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(api.aiReretouchRequests.isEmpty)
|
||||
}
|
||||
|
||||
func testMissingRequiredTemplateDisablesMatchingWorkflow() async {
|
||||
func testMissingOptionalAtmosphereStillAllowsSelectingRefinedInAllReretouchWorkflow() async {
|
||||
let api = makeAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [template(11, "清透")],
|
||||
atmosphereTemplates: [],
|
||||
coverTemplates: []
|
||||
coverTemplates: [],
|
||||
remainingQuota: 100
|
||||
)
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
scenicId: 18,
|
||||
@@ -187,7 +209,43 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
XCTAssertFalse(viewModel.canSubmit)
|
||||
XCTAssertEqual(viewModel.validationMessage, "暂无可用的氛围感修图模板")
|
||||
viewModel.toggleTemplate(id: 11, category: .refined)
|
||||
XCTAssertTrue(viewModel.canSubmit)
|
||||
XCTAssertNil(viewModel.validationMessage)
|
||||
XCTAssertEqual(viewModel.requiredQuota, 1)
|
||||
}
|
||||
|
||||
func testMissingRequiredRefinedAndCoverTemplatesDisableMatchingWorkflows() async {
|
||||
let refinedAPI = makeAPI()
|
||||
refinedAPI.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [],
|
||||
atmosphereTemplates: [template(21, "暖阳")],
|
||||
coverTemplates: [template(31, "杂志")],
|
||||
remainingQuota: 100
|
||||
)
|
||||
let reretouch = TravelAlbumAIRetouchTemplateViewModel(
|
||||
scenicId: 18,
|
||||
workflow: .reretouch(materialId: 7, batchId: 70, type: .refined)
|
||||
)
|
||||
await reretouch.loadTemplates(api: refinedAPI)
|
||||
XCTAssertFalse(reretouch.canSubmit)
|
||||
XCTAssertEqual(reretouch.validationMessage, "暂无可用的原图精修模板")
|
||||
|
||||
let coverAPI = makeAPI()
|
||||
coverAPI.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [template(11, "清透")],
|
||||
atmosphereTemplates: [template(21, "暖阳")],
|
||||
coverTemplates: [],
|
||||
remainingQuota: 100
|
||||
)
|
||||
let batch = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [1, 2, 3, 4]
|
||||
)
|
||||
await batch.loadTemplates(api: coverAPI)
|
||||
XCTAssertFalse(batch.canSubmit)
|
||||
XCTAssertEqual(batch.validationMessage, "暂无可用的封面风格模板")
|
||||
}
|
||||
|
||||
func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async {
|
||||
@@ -216,7 +274,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
var message: String?
|
||||
var submitted = false
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
viewModel.onSubmitted = { submitted = true }
|
||||
viewModel.onSubmitted = { _ in submitted = true }
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
await viewModel.submit(api: api)
|
||||
|
||||
@@ -162,7 +162,7 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
|
||||
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
|
||||
let data = envelopeJSON(
|
||||
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg"}],"remaining_quota":12}"#
|
||||
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg","before_url":"https://cdn/refined-before.jpg","after_url":"https://cdn/refined-after.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg","before_url":"https://cdn/atmosphere-before.jpg","after_url":"https://cdn/atmosphere-after.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg","before_url":"https://cdn/cover-before.jpg","after_url":"https://cdn/cover-after.jpg"}],"remaining_quota":12}"#
|
||||
)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
@@ -170,6 +170,8 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
let response = try await api.aiRetouchTemplates(scenicId: 18)
|
||||
|
||||
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
|
||||
XCTAssertEqual(response.refinedTemplates.first?.beforeURL, "https://cdn/refined-before.jpg")
|
||||
XCTAssertEqual(response.refinedTemplates.first?.afterURL, "https://cdn/refined-after.jpg")
|
||||
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
|
||||
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
|
||||
XCTAssertEqual(response.remainingQuota, 12)
|
||||
@@ -181,7 +183,7 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws {
|
||||
let session = MockURLSession(responses: [envelopeJSON(#"{"task_id":9}"#)])
|
||||
let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 9, albumId: 6)])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
try await api.submitAIRetouch(
|
||||
@@ -213,7 +215,7 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws {
|
||||
let session = MockURLSession(responses: [envelopeJSON(#"{"accepted":true}"#)])
|
||||
let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 10, albumId: 6)])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
try await api.submitAIRetouch(
|
||||
@@ -238,9 +240,10 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
|
||||
func testSubmitAIReretouchEncodesOnlyFieldsRequiredByEachType() async throws {
|
||||
let session = MockURLSession(responses: [
|
||||
envelopeJSON(#"{"accepted":true}"#),
|
||||
envelopeJSON(#"{"accepted":true}"#),
|
||||
envelopeJSON(#"{"accepted":true}"#),
|
||||
jobSubmissionJSON(batchId: 51, albumId: 6),
|
||||
jobSubmissionJSON(batchId: 52, albumId: 6),
|
||||
jobSubmissionJSON(batchId: 53, albumId: 6),
|
||||
jobSubmissionJSON(batchId: 54, albumId: 6),
|
||||
])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
@@ -271,13 +274,22 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
atmosphereTemplateId: 22
|
||||
)
|
||||
)
|
||||
try await api.submitAIReretouch(
|
||||
TravelAlbumAIReretouchRequest(
|
||||
id: 14,
|
||||
aiRetouchBatchId: 54,
|
||||
type: .all,
|
||||
refinedTemplateId: 21,
|
||||
atmosphereTemplateId: nil
|
||||
)
|
||||
)
|
||||
|
||||
let bodies = try session.requests.map { request in
|
||||
try JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||
}
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, Array(
|
||||
repeating: "/api/yf-handset-app/photog/travel-album/ai-reretouch",
|
||||
count: 3
|
||||
count: 4
|
||||
))
|
||||
XCTAssertEqual(Set(bodies[0]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "refined_template_id"])
|
||||
XCTAssertEqual(Set(bodies[1]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "atmosphere_template_id"])
|
||||
@@ -288,9 +300,57 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
"refined_template_id",
|
||||
"atmosphere_template_id",
|
||||
])
|
||||
XCTAssertEqual(Set(bodies[3]?.keys.map { $0 } ?? []), [
|
||||
"id",
|
||||
"ai_retouch_batch_id",
|
||||
"type",
|
||||
"refined_template_id",
|
||||
])
|
||||
XCTAssertEqual(bodies[0]?["type"] as? Int, 1)
|
||||
XCTAssertEqual(bodies[1]?["type"] as? Int, 2)
|
||||
XCTAssertEqual(bodies[2]?["type"] as? Int, 3)
|
||||
XCTAssertEqual(bodies[3]?["type"] as? Int, 3)
|
||||
}
|
||||
|
||||
func testAIJobListBuildsCursorQueryAndDecodesUnknownStatusSafely() async throws {
|
||||
let data = envelopeJSON(
|
||||
#"{"items":[{"ai_retouch_batch_id":91,"user_equity_travel_id":6,"scope":"album","status":"future_status","album":{"id":6,"name":"九寨沟旅拍","user_phone":"138****0000","cover_url":""},"source_count":1,"outputs":[{"type":"refined","count":1}],"preview_images":[],"progress":{"total":1,"queued":1,"processing":0,"succeeded":0,"failed":0,"canceled":0},"estimated_finish_at":null,"failure_summary":null,"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":null}],"next_cursor":"cursor-2","has_more":true}"#
|
||||
)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
let response = try await api.aiRetouchJobList(
|
||||
statusGroup: .inProgress,
|
||||
limit: 20,
|
||||
cursor: "cursor-1"
|
||||
)
|
||||
|
||||
XCTAssertEqual(response.items.first?.status, .unknown("future_status"))
|
||||
XCTAssertEqual(response.nextCursor, "cursor-2")
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-job-list")
|
||||
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(query?.first { $0.name == "status_group" }?.value, "in_progress")
|
||||
XCTAssertEqual(query?.first { $0.name == "limit" }?.value, "20")
|
||||
XCTAssertEqual(query?.first { $0.name == "cursor" }?.value, "cursor-1")
|
||||
}
|
||||
|
||||
func testAIJobInfoDecodesPerOutputFailureReason() async throws {
|
||||
let data = envelopeJSON(
|
||||
#"{"ai_retouch_batch_id":91,"user_equity_travel_id":6,"scope":"album","status":"partially_succeeded","album":{"id":6,"name":"九寨沟旅拍","user_phone":"138****0000","cover_url":""},"source_count":1,"outputs":[{"type":"refined","count":1}],"progress":{"total":1,"queued":0,"processing":0,"succeeded":0,"failed":1,"canceled":0},"quota_settlement":{"status":"settled","reserved_units":1,"consumed_units":0,"released_units":1,"cover_units":0},"targets":[{"target_id":1,"source_material":{"id":11,"file_name":"A.JPG","thumbnail_url":""},"input_material_ids":[11],"output_type":"refined","template":{"id":21,"name":"清透"},"status":"failed","result_asset":null,"error":{"code":"FACE_NOT_FOUND","message":"未识别到清晰人脸,请更换照片","retryable":true},"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":"2026-08-14T06:01:00Z"}],"estimated_finish_at":null,"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":"2026-08-14T06:01:00Z","duration_seconds":60}"#
|
||||
)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
let detail = try await api.aiRetouchJobInfo(batchId: 91)
|
||||
|
||||
XCTAssertEqual(detail.status, .partiallySucceeded)
|
||||
XCTAssertEqual(detail.targets.first?.displayFailureMessage, "未识别到清晰人脸,请更换照片")
|
||||
XCTAssertEqual(detail.targets.first?.error?.retryable, true)
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-job-info")
|
||||
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(query?.first { $0.name == "ai_retouch_batch_id" }?.value, "91")
|
||||
}
|
||||
|
||||
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||
@@ -298,4 +358,10 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
{"code":100000,"msg":"success","data":\(dataJSON)}
|
||||
""".data(using: .utf8)!
|
||||
}
|
||||
|
||||
private func jobSubmissionJSON(batchId: Int, albumId: Int) -> Data {
|
||||
envelopeJSON(
|
||||
#"{"ai_retouch_batch_id":\#(batchId),"user_equity_travel_id":\#(albumId),"status":"queued","progress":{"total":3,"queued":3,"processing":0,"succeeded":0,"failed":0,"canceled":0},"created_at":"2026-08-14T06:00:00Z"}"#
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,267 @@ import XCTest
|
||||
/// 相册管理页刷新、预览与选择态交互测试。
|
||||
@MainActor
|
||||
final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
func testAIJobDetailMatchesDesignCardHierarchyAndShowsInlineFailureReason() async throws {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let source = TravelAlbumAIJobSourceMaterial(
|
||||
id: 691,
|
||||
fileName: "beauty_1757851944526_这个文件标题特别长用于验证布局不会挤压状态.jpg",
|
||||
thumbnailURL: ""
|
||||
)
|
||||
let failedTarget = TravelAlbumAIJobTarget(
|
||||
targetId: 3,
|
||||
sourceMaterial: source,
|
||||
inputMaterialIds: [691],
|
||||
outputType: .refined,
|
||||
template: TravelAlbumAIJobTemplate(id: 7, name: "自然通透"),
|
||||
status: .failed,
|
||||
resultAsset: nil,
|
||||
error: TravelAlbumAIJobError(
|
||||
code: "VENDOR_TIMEOUT",
|
||||
message: "AI服务处理超时,请重新修图",
|
||||
retryable: true
|
||||
),
|
||||
createdAt: "2026-08-14T03:20:43.000Z",
|
||||
startedAt: nil,
|
||||
finishedAt: "2026-08-14T03:21:43.000Z"
|
||||
)
|
||||
api.aiJobDetailResponse = TravelAlbumAIJobDetail(
|
||||
aiRetouchBatchId: 59,
|
||||
userEquityTravelId: 17,
|
||||
scope: "batch",
|
||||
status: .processing,
|
||||
album: TravelAlbumAIJobAlbum(
|
||||
id: 17,
|
||||
name: "旅拍相册",
|
||||
userPhone: "13222319413",
|
||||
coverURL: ""
|
||||
),
|
||||
sourceCount: 1,
|
||||
outputs: [
|
||||
TravelAlbumAIJobOutput(type: .refined, count: 4),
|
||||
TravelAlbumAIJobOutput(type: .atmosphere, count: 4),
|
||||
TravelAlbumAIJobOutput(type: .cover, count: 1),
|
||||
],
|
||||
progress: TravelAlbumAIJobProgress(
|
||||
total: 9,
|
||||
queued: 2,
|
||||
processing: 1,
|
||||
succeeded: 5,
|
||||
failed: 1,
|
||||
canceled: 0
|
||||
),
|
||||
quotaSettlement: TravelAlbumAIJobQuotaSettlement(
|
||||
status: "reserved",
|
||||
reservedUnits: 9,
|
||||
consumedUnits: 6,
|
||||
releasedUnits: 0,
|
||||
coverUnits: 1
|
||||
),
|
||||
targets: [failedTarget],
|
||||
estimatedFinishAt: "2026-08-14T03:32:00.000Z",
|
||||
createdAt: "2026-08-14T03:20:43.000Z",
|
||||
startedAt: "2026-08-14T03:20:44.000Z",
|
||||
finishedAt: nil,
|
||||
durationSeconds: nil
|
||||
)
|
||||
let controller = TravelAlbumAIJobDetailViewController(batchId: 59, api: api)
|
||||
let navigationController = UINavigationController(rootViewController: controller)
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||
window.rootViewController = navigationController
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
|
||||
controller.loadViewIfNeeded()
|
||||
await waitUntil {
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "aiRetouchJob.detail.statusCard"
|
||||
} != nil
|
||||
}
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
for identifier in [
|
||||
"aiRetouchJob.detail.statusCard",
|
||||
"aiRetouchJob.detail.albumCard",
|
||||
"aiRetouchJob.detail.contentCard",
|
||||
"aiRetouchJob.detail.processingCard",
|
||||
"aiRetouchJob.detail.albumButton",
|
||||
] {
|
||||
XCTAssertNotNil(controller.view.findSubview { $0.accessibilityIdentifier == identifier })
|
||||
}
|
||||
let labels = controller.view.allLabels().compactMap(\.text)
|
||||
XCTAssertTrue(labels.contains("修图中"))
|
||||
XCTAssertTrue(labels.contains("已完成 6 / 9"))
|
||||
XCTAssertTrue(labels.contains("67%"))
|
||||
XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图"))
|
||||
XCTAssertFalse(labels.contains("去相册处理"))
|
||||
XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") })
|
||||
let contentCard = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "aiRetouchJob.detail.contentCard"
|
||||
})
|
||||
let refinedChip = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.refined"
|
||||
})
|
||||
let atmosphereChip = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.atmosphere"
|
||||
})
|
||||
let coverChip = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "aiRetouchJob.contentChip.cover"
|
||||
})
|
||||
XCTAssertEqual(refinedChip.backgroundColor?.travelAlbumTestHexRGB, 0xEAF3FF)
|
||||
XCTAssertEqual(atmosphereChip.backgroundColor?.travelAlbumTestHexRGB, 0xFFF2DC)
|
||||
XCTAssertEqual(coverChip.backgroundColor?.travelAlbumTestHexRGB, 0xF2ECFF)
|
||||
XCTAssertNil(refinedChip.findSubview { $0 is UIImageView })
|
||||
XCTAssertNil(atmosphereChip.findSubview { $0 is UIImageView })
|
||||
XCTAssertNil(coverChip.findSubview { $0 is UIImageView })
|
||||
XCTAssertGreaterThan(atmosphereChip.bounds.width, refinedChip.bounds.width)
|
||||
let coverFrame = coverChip.convert(coverChip.bounds, to: contentCard)
|
||||
XCTAssertLessThan(coverFrame.maxX, contentCard.bounds.maxX - 16)
|
||||
let titleLabel = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "aiRetouchJob.target.3.title"
|
||||
})
|
||||
let templateLabel = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "aiRetouchJob.target.3.template"
|
||||
})
|
||||
let statusView = try XCTUnwrap(controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "aiRetouchJob.target.3.status"
|
||||
})
|
||||
let titleFrame = titleLabel.convert(titleLabel.bounds, to: controller.view)
|
||||
let templateFrame = templateLabel.convert(templateLabel.bounds, to: controller.view)
|
||||
let statusFrame = statusView.convert(statusView.bounds, to: controller.view)
|
||||
XCTAssertLessThanOrEqual(titleFrame.maxX, controller.view.bounds.maxX - 18)
|
||||
XCTAssertGreaterThan(statusFrame.minY, templateFrame.maxY)
|
||||
XCTAssertLessThanOrEqual(statusFrame.maxX, controller.view.bounds.maxX - 18)
|
||||
let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
|
||||
window.layer.render(in: context.cgContext)
|
||||
}
|
||||
let detailAttachment = XCTAttachment(image: detailScreenshot)
|
||||
detailAttachment.name = "AI修图任务详情设计还原"
|
||||
detailAttachment.lifetime = .keepAlways
|
||||
add(detailAttachment)
|
||||
}
|
||||
|
||||
func testAIJobListCardMatchesDesignStructureAndKeepsSinglePreviewInGrid() async throws {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let job = TravelAlbumAIJobSummary(
|
||||
aiRetouchBatchId: 59,
|
||||
userEquityTravelId: 17,
|
||||
scope: "batch",
|
||||
status: .queued,
|
||||
album: TravelAlbumAIJobAlbum(
|
||||
id: 17,
|
||||
name: "2026-06-17-005",
|
||||
userPhone: "13222319413",
|
||||
coverURL: ""
|
||||
),
|
||||
sourceCount: 1,
|
||||
outputs: [
|
||||
TravelAlbumAIJobOutput(type: .refined, count: 1),
|
||||
TravelAlbumAIJobOutput(type: .atmosphere, count: 1),
|
||||
],
|
||||
previewImages: [TravelAlbumAIJobPreviewImage(materialId: 691, thumbnailURL: "")],
|
||||
progress: TravelAlbumAIJobProgress(
|
||||
total: 2,
|
||||
queued: 2,
|
||||
processing: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
canceled: 0
|
||||
),
|
||||
estimatedFinishAt: "2026-08-14T03:23:34.147Z",
|
||||
failureSummary: nil,
|
||||
createdAt: "2026-08-14T03:20:43.000Z",
|
||||
startedAt: nil,
|
||||
finishedAt: nil
|
||||
)
|
||||
api.aiJobListResponses = [
|
||||
TravelAlbumAIJobListResponse(items: [job], nextCursor: nil, hasMore: false),
|
||||
]
|
||||
let controller = TravelAlbumAIJobListViewController(api: api)
|
||||
let navigationController = UINavigationController(rootViewController: controller)
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||
window.rootViewController = navigationController
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
|
||||
controller.loadViewIfNeeded()
|
||||
await waitUntil { api.aiJobListRequests.count == 1 }
|
||||
try await Task.sleep(for: .milliseconds(150))
|
||||
controller.view.layoutIfNeeded()
|
||||
let collectionView = try XCTUnwrap(
|
||||
controller.view.findSubview { $0 is UICollectionView } as? UICollectionView
|
||||
)
|
||||
collectionView.layoutIfNeeded()
|
||||
let cell = try XCTUnwrap(collectionView.cellForItem(at: IndexPath(item: 0, section: 0)))
|
||||
let albumCover = try XCTUnwrap(
|
||||
cell.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.albumCover" }
|
||||
)
|
||||
let firstPreview = try XCTUnwrap(
|
||||
cell.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.preview.0" }
|
||||
)
|
||||
let filterContainer = try XCTUnwrap(
|
||||
controller.view.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.filterContainer" }
|
||||
)
|
||||
|
||||
XCTAssertEqual(filterContainer.bounds.height, 46, accuracy: 0.5)
|
||||
XCTAssertEqual(albumCover.bounds.width, 68, accuracy: 2)
|
||||
XCTAssertEqual(albumCover.bounds.height, 68, accuracy: 2)
|
||||
XCTAssertLessThan(firstPreview.bounds.width, cell.bounds.width * 0.4)
|
||||
XCTAssertTrue(cell.allLabels().contains { $0.text == "排队中" })
|
||||
XCTAssertTrue(cell.allLabels().contains { $0.text == "已完成 0 / 2" })
|
||||
XCTAssertTrue(cell.allLabels().contains { $0.text == "0%" })
|
||||
XCTAssertTrue(cell.allLabels().contains { $0.text == "精修 1张 · 氛围感 1张" })
|
||||
XCTAssertTrue(cell.allLabels().contains { $0.text == "查看详情" })
|
||||
let listScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
|
||||
window.layer.render(in: context.cgContext)
|
||||
}
|
||||
let listAttachment = XCTAttachment(image: listScreenshot)
|
||||
listAttachment.name = "AI修图任务列表设计还原"
|
||||
listAttachment.lifetime = .keepAlways
|
||||
add(listAttachment)
|
||||
}
|
||||
|
||||
func testAIJobEmptyInProgressFilterDismissesGlobalLoading() async throws {
|
||||
GlobalLoadingManager.shared.hideAll()
|
||||
defer { GlobalLoadingManager.shared.hideAll() }
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiJobListDelayNanoseconds = 30_000_000
|
||||
api.aiJobListResponses = [
|
||||
TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false),
|
||||
TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false),
|
||||
]
|
||||
let controller = TravelAlbumAIJobListViewController(api: api)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.viewDidAppear(false)
|
||||
defer { controller.viewWillDisappear(false) }
|
||||
|
||||
await waitUntil { api.aiJobListRequests.count == 1 && !GlobalLoadingManager.shared.isShowing }
|
||||
let inProgressButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
($0 as? UIButton)?.accessibilityLabel == "筛选:进行中"
|
||||
} as? UIButton
|
||||
)
|
||||
|
||||
inProgressButton.sendActions(for: .touchUpInside)
|
||||
|
||||
await waitUntil {
|
||||
api.aiJobListRequests.count == 2 &&
|
||||
api.aiJobListRequests.last?.statusGroup == .inProgress &&
|
||||
!GlobalLoadingManager.shared.isShowing
|
||||
}
|
||||
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
||||
}
|
||||
|
||||
func testAIJobEntryOnlyAppearsOnAlbumManagementNavigationBar() {
|
||||
let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI())
|
||||
detail.setupNavigationBar()
|
||||
let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI())
|
||||
entry.setupNavigationBar()
|
||||
|
||||
XCTAssertTrue(detail.navigationItem.rightBarButtonItems?.contains { $0.title == "修图任务" } == true)
|
||||
XCTAssertNil(entry.navigationItem.rightBarButtonItem)
|
||||
XCTAssertTrue(entry.navigationItem.rightBarButtonItems?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
func testPullToRefreshReloadsGridAndEndsRefreshing() async throws {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.infoResponse = TravelAlbum(id: 2, name: "测试相册")
|
||||
@@ -67,6 +328,79 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertEqual(failure, .failure("服务暂不可用"))
|
||||
}
|
||||
|
||||
func testPreviewCounterUsesHorizontalPaddingAndContentDrivenWidth() throws {
|
||||
let controller = TravelAlbumPhotoPreviewViewController(
|
||||
projects: [makePreviewProject(id: 1, fileName: "原图.jpg")],
|
||||
totalCount: 10_000,
|
||||
startProjectIndex: 0
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let counterLabel = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.previewCounterLabel"
|
||||
} as? UILabel
|
||||
)
|
||||
let counterContainer = try XCTUnwrap(counterLabel.superview)
|
||||
let expectedWidth = max(48, counterLabel.intrinsicContentSize.width + 24)
|
||||
|
||||
XCTAssertEqual(counterContainer.bounds.width, expectedWidth, accuracy: 0.5)
|
||||
XCTAssertEqual(counterLabel.frame.minX, 12, accuracy: 0.5)
|
||||
XCTAssertEqual(counterContainer.bounds.width - counterLabel.frame.maxX, 12, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testPreviewAppliesRequestedInitialKindForCoverOnlyProject() throws {
|
||||
let cover = TravelAlbumPreviewAsset(
|
||||
id: "cover-897",
|
||||
kind: .cover,
|
||||
fileURL: "https://example.com/final.png",
|
||||
coverURL: "https://example.com/final.png",
|
||||
fileName: "AI封面",
|
||||
fileSize: 0
|
||||
)
|
||||
let controller = TravelAlbumPhotoPreviewViewController(
|
||||
projects: [TravelAlbumPreviewProject(originalMaterialId: 897, assets: [cover])],
|
||||
totalCount: 1,
|
||||
startProjectIndex: 0,
|
||||
startKind: .cover,
|
||||
allowsActions: false
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let highResolutionButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.previewHighResolutionButton"
|
||||
} as? UIButton
|
||||
)
|
||||
let loadingIndicator = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.previewLoadingIndicator"
|
||||
}
|
||||
)
|
||||
let activityIndicator = try XCTUnwrap(
|
||||
loadingIndicator.findSubview { $0 is UIActivityIndicatorView } as? UIActivityIndicatorView
|
||||
)
|
||||
let fileSizeLabel = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.previewFileSizeLabel"
|
||||
} as? UILabel
|
||||
)
|
||||
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "AI封面" })
|
||||
XCTAssertTrue(highResolutionButton.isEnabled)
|
||||
XCTAssertTrue(fileSizeLabel.isHidden)
|
||||
XCTAssertNil(fileSizeLabel.text)
|
||||
XCTAssertEqual(fileSizeLabel.bounds.height, 0, accuracy: 0.5)
|
||||
XCTAssertEqual(loadingIndicator.bounds.size, CGSize(width: 44, height: 44))
|
||||
XCTAssertEqual(loadingIndicator.backgroundColor?.travelAlbumTestHexRGB, 0x27272A)
|
||||
XCTAssertEqual(loadingIndicator.accessibilityLabel, "图片加载中")
|
||||
XCTAssertEqual(activityIndicator.color.travelAlbumTestHexRGB, 0xFFFFFF)
|
||||
}
|
||||
|
||||
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.deleteMaterialDelayNanoseconds = 1_000_000
|
||||
@@ -305,7 +639,29 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertEqual(highResolutionButton.configuration?.title, "查看高清图")
|
||||
XCTAssertEqual(segmentedControl.numberOfSegments, 3)
|
||||
XCTAssertEqual(segmentedControl.selectedSegmentIndex, 0)
|
||||
XCTAssertFalse(segmentedControl.apportionsSegmentWidthsByContent)
|
||||
XCTAssertEqual(segmentedControl.selectedSegmentTintColor, UIColor.white.withAlphaComponent(0.2))
|
||||
XCTAssertEqual(
|
||||
segmentedControl.titleTextAttributes(for: .normal)?[.foregroundColor] as? UIColor,
|
||||
UIColor.white.withAlphaComponent(0.7)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
segmentedControl.titleTextAttributes(for: .selected)?[.foregroundColor] as? UIColor,
|
||||
UIColor.white
|
||||
)
|
||||
XCTAssertNil(segmentedControl.backgroundImage(for: .selected, barMetrics: .default))
|
||||
XCTAssertNil(
|
||||
segmentedControl.dividerImage(
|
||||
forLeftSegmentState: .normal,
|
||||
rightSegmentState: .normal,
|
||||
barMetrics: .default
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(segmentedControl.bounds.width, 180, accuracy: 0.5)
|
||||
XCTAssertEqual(segmentedControl.bounds.height, 32, accuracy: 1)
|
||||
XCTAssertGreaterThanOrEqual(highResolutionButton.frame.minX - segmentedControl.frame.maxX, 12)
|
||||
XCTAssertEqual(backButton.bounds.size, CGSize(width: 44, height: 44))
|
||||
XCTAssertEqual(backButton.configuration?.image, UIImage(systemName: "chevron.down"))
|
||||
XCTAssertNil(backButton.configuration?.baseBackgroundColor)
|
||||
XCTAssertEqual(actionStack.spacing, 10)
|
||||
XCTAssertTrue(actionStack.isLayoutMarginsRelativeArrangement)
|
||||
@@ -438,7 +794,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||
viewModel: viewModel,
|
||||
api: api,
|
||||
onSubmitted: {}
|
||||
onSubmitted: { _ in }
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
@@ -493,7 +849,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片")
|
||||
XCTAssertEqual(
|
||||
tipsLabel.text,
|
||||
"每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面"
|
||||
"Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
|
||||
)
|
||||
XCTAssertFalse(tipsContainer.isHidden)
|
||||
XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xF4F8FF)
|
||||
@@ -523,7 +879,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||
viewModel: viewModel,
|
||||
api: api,
|
||||
onSubmitted: {}
|
||||
onSubmitted: { _ in }
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
@@ -538,7 +894,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
let accessibleLabels = controller.view.allAccessibilityLabels()
|
||||
XCTAssertTrue(accessibleLabels.contains("原图精修,必选"))
|
||||
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||||
XCTAssertTrue(accessibleLabels.contains("封面风格,赠送 · 不占额度"))
|
||||
XCTAssertTrue(accessibleLabels.contains("封面风格,必选 · 赠送 · 不占额度"))
|
||||
|
||||
collectionView.scrollToItem(
|
||||
at: IndexPath(item: 0, section: 4),
|
||||
@@ -562,6 +918,43 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度")
|
||||
}
|
||||
|
||||
func testOriginalTabReretouchSheetShowsBothTemplateGroupsAsOptional() async throws {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
||||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
||||
remainingQuota: 8
|
||||
)
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
scenicId: 18,
|
||||
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
|
||||
)
|
||||
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||
viewModel: viewModel,
|
||||
api: api,
|
||||
onSubmitted: { _ in }
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let confirmButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
||||
} as? UIButton
|
||||
)
|
||||
let accessibleLabels = controller.view.allAccessibilityLabels()
|
||||
XCTAssertTrue(accessibleLabels.contains("原图精修,选填"))
|
||||
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||||
XCTAssertFalse(confirmButton.isEnabled)
|
||||
|
||||
viewModel.toggleTemplate(id: 2, category: .atmosphere)
|
||||
await waitUntil { confirmButton.isEnabled }
|
||||
XCTAssertNil(viewModel.selectedRefinedTemplateId)
|
||||
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 2)
|
||||
}
|
||||
|
||||
func testAIRetouchTemplateCellExposesSelectedState() {
|
||||
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
|
||||
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
|
||||
@@ -591,38 +984,99 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertEqual(cell.accessibilityValue, "未选择")
|
||||
}
|
||||
|
||||
func testAIRetouchTemplatePreviewUsesImmersiveZoomableLayout() throws {
|
||||
let template = TravelAlbumAIRetouchTemplate(
|
||||
id: 2,
|
||||
name: "简约留白",
|
||||
previewURL: ""
|
||||
func testBeforeAfterComparisonUsesImmersiveAccessibleLayout() throws {
|
||||
let controller = BeforeAfterComparisonViewController(
|
||||
viewModel: BeforeAfterComparisonViewModel(
|
||||
content: BeforeAfterComparisonContent(
|
||||
beforeURL: "https://cdn.example.com/before.jpg",
|
||||
afterURL: "https://cdn.example.com/after.jpg"
|
||||
)
|
||||
)
|
||||
)
|
||||
let controller = TravelAlbumAIRetouchTemplatePreviewViewController(template: template)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let scrollView = try XCTUnwrap(
|
||||
let imageContainer = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewScrollView"
|
||||
} as? UIScrollView
|
||||
$0.accessibilityIdentifier == "travelAlbum.beforeAfterImageContainer"
|
||||
}
|
||||
)
|
||||
let backButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplatePreviewBackButton"
|
||||
$0.accessibilityIdentifier == "travelAlbum.beforeAfterBackButton"
|
||||
} as? UIButton
|
||||
)
|
||||
let divider = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.beforeAfterDivider"
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(controller.view.backgroundColor, .black)
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "简约留白" })
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "双指缩放查看细节" })
|
||||
XCTAssertEqual(scrollView.minimumZoomScale, 1)
|
||||
XCTAssertEqual(scrollView.maximumZoomScale, 4)
|
||||
XCTAssertEqual(controller.view.backgroundColor, UIColor(hex: 0x05070D))
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "原图" })
|
||||
XCTAssertTrue(controller.view.allLabels().contains { $0.text == "效果图" })
|
||||
XCTAssertEqual(imageContainer.layer.cornerRadius, 0)
|
||||
XCTAssertTrue(divider.accessibilityTraits.contains(.adjustable))
|
||||
XCTAssertEqual(divider.accessibilityValue, "原图占比 50%")
|
||||
XCTAssertGreaterThanOrEqual(backButton.bounds.width, 44)
|
||||
XCTAssertGreaterThanOrEqual(backButton.bounds.height, 44)
|
||||
}
|
||||
|
||||
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
|
||||
func testPreviewComparisonButtonOnlyShowsForSelectedAIResult() throws {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
defer { UIView.setAnimationsEnabled(true) }
|
||||
let project = TravelAlbumPreviewProject(
|
||||
originalMaterialId: 7,
|
||||
assets: [
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "original-7",
|
||||
kind: .original,
|
||||
fileURL: "https://cdn.example.com/original.jpg",
|
||||
coverURL: "",
|
||||
fileName: "原图.jpg",
|
||||
fileSize: 10
|
||||
),
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "retouched-7",
|
||||
kind: .retouched,
|
||||
fileURL: "https://cdn.example.com/retouched.jpg",
|
||||
coverURL: "",
|
||||
fileName: "精修.jpg",
|
||||
fileSize: 10
|
||||
),
|
||||
]
|
||||
)
|
||||
let controller = TravelAlbumPhotoPreviewViewController(
|
||||
projects: [project],
|
||||
totalCount: 1,
|
||||
startProjectIndex: 0
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
controller.view.layoutIfNeeded()
|
||||
let segmentedControl = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.previewVariantSegmentedControl"
|
||||
} as? UISegmentedControl
|
||||
)
|
||||
let comparisonButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.previewComparisonButton"
|
||||
} as? UIButton
|
||||
)
|
||||
|
||||
XCTAssertTrue(comparisonButton.isHidden)
|
||||
segmentedControl.selectedSegmentIndex = 1
|
||||
segmentedControl.sendActions(for: .valueChanged)
|
||||
|
||||
XCTAssertFalse(comparisonButton.isHidden)
|
||||
XCTAssertNil(comparisonButton.configuration?.title)
|
||||
XCTAssertNotNil(comparisonButton.configuration?.image)
|
||||
XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48))
|
||||
}
|
||||
|
||||
func testPreviewAIRetouchFromRetouchedTabAllowsBothOptionalTemplates() async throws {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
defer { UIView.setAnimationsEnabled(true) }
|
||||
let api = TravelAlbumMockAPI()
|
||||
@@ -653,13 +1107,15 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
),
|
||||
]
|
||||
)
|
||||
var submissionNotified = false
|
||||
let controller = TravelAlbumPhotoPreviewViewController(
|
||||
projects: [project],
|
||||
totalCount: 1,
|
||||
startProjectIndex: 0,
|
||||
albumId: 8,
|
||||
scenicIdProvider: { 18 },
|
||||
aiRetouchAPI: api
|
||||
aiRetouchAPI: api,
|
||||
onAIRetouchSubmitted: { submissionNotified = true }
|
||||
)
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||
window.rootViewController = controller
|
||||
@@ -687,7 +1143,16 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
||||
} as? UIButton
|
||||
)
|
||||
await waitUntil { api.aiRetouchTemplateScenicIds.count == 1 && confirmButton.isEnabled }
|
||||
let collectionView = try XCTUnwrap(
|
||||
sheet.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
|
||||
} as? UICollectionView
|
||||
)
|
||||
await waitUntil {
|
||||
api.aiRetouchTemplateScenicIds.count == 1 &&
|
||||
collectionView.numberOfSections > 2 &&
|
||||
collectionView.numberOfItems(inSection: 2) == 1
|
||||
}
|
||||
sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
sheet.view.layoutIfNeeded()
|
||||
|
||||
@@ -695,7 +1160,11 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
label.isHidden ? nil : label.text
|
||||
}
|
||||
XCTAssertTrue(visibleLabelTexts.contains("原图精修"))
|
||||
XCTAssertFalse(visibleLabelTexts.contains { $0.contains("氛围感修图") })
|
||||
XCTAssertTrue(visibleLabelTexts.contains("氛围感修图"))
|
||||
let accessibleLabels = sheet.view.allAccessibilityLabels()
|
||||
XCTAssertTrue(accessibleLabels.contains("原图精修,选填"))
|
||||
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||||
XCTAssertFalse(confirmButton.isEnabled)
|
||||
let selectionCountLabel = try XCTUnwrap(
|
||||
sheet.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel"
|
||||
@@ -714,12 +1183,26 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片")
|
||||
XCTAssertTrue(tipsContainer.isHidden)
|
||||
XCTAssertTrue(tipsLabel.isHidden)
|
||||
collectionView.delegate?.collectionView?(
|
||||
collectionView,
|
||||
didSelectItemAt: IndexPath(item: 0, section: 2)
|
||||
)
|
||||
await waitUntil { confirmButton.isEnabled }
|
||||
confirmButton.sendActions(for: .touchUpInside)
|
||||
await waitUntil { api.aiReretouchRequests.count == 1 }
|
||||
await waitUntil { controller.presentedViewController == nil }
|
||||
await waitUntil { submissionNotified }
|
||||
|
||||
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined)
|
||||
XCTAssertNil(controller.presentedViewController)
|
||||
XCTAssertEqual(
|
||||
api.aiReretouchRequests.first,
|
||||
TravelAlbumAIReretouchRequest(
|
||||
id: 7,
|
||||
aiRetouchBatchId: 70,
|
||||
type: .all,
|
||||
refinedTemplateId: nil,
|
||||
atmosphereTemplateId: 21
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(submissionNotified)
|
||||
XCTAssertTrue(window.rootViewController === controller)
|
||||
}
|
||||
|
||||
@@ -816,9 +1299,8 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertFalse(controller.view.allLabels().contains { $0.text == "刷新失败" })
|
||||
}
|
||||
|
||||
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
|
||||
func testMaterialCellShowsIndependentPurchaseAndAIRetouchBadgesWithoutOverlappingSelectionCheck() throws {
|
||||
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
||||
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
|
||||
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
|
||||
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
|
||||
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
|
||||
@@ -837,12 +1319,12 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
|
||||
let badgeView = try XCTUnwrap(
|
||||
cell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||||
}
|
||||
)
|
||||
let badgeLabel = try XCTUnwrap(
|
||||
cell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel"
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadgeLabel"
|
||||
} as? UILabel
|
||||
)
|
||||
let selectionCheck = try XCTUnwrap(
|
||||
@@ -855,22 +1337,61 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertEqual(badgeLabel.text, expectedText)
|
||||
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
|
||||
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
|
||||
XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true)
|
||||
XCTAssertTrue(cell.accessibilityLabel?.contains("修图状态:\(expectedText)") == true)
|
||||
}
|
||||
|
||||
let combinedCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||
combinedCell.apply(
|
||||
material: TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2),
|
||||
selectionMode: true,
|
||||
selected: false
|
||||
)
|
||||
combinedCell.layoutIfNeeded()
|
||||
let purchaseBadge = try XCTUnwrap(
|
||||
combinedCell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
|
||||
}
|
||||
)
|
||||
let purchaseLabel = try XCTUnwrap(
|
||||
combinedCell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadgeLabel"
|
||||
} as? UILabel
|
||||
)
|
||||
let aiRetouchBadge = try XCTUnwrap(
|
||||
combinedCell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||||
}
|
||||
)
|
||||
XCTAssertFalse(purchaseBadge.isHidden)
|
||||
XCTAssertFalse(aiRetouchBadge.isHidden)
|
||||
XCTAssertEqual(purchaseLabel.text, "已购")
|
||||
XCTAssertEqual(purchaseBadge.backgroundColor?.travelAlbumTestHexRGB, 0x475569)
|
||||
XCTAssertGreaterThan(purchaseBadge.frame.minY, aiRetouchBadge.frame.maxY)
|
||||
XCTAssertEqual(purchaseBadge.frame.minX, 6, accuracy: 0.5)
|
||||
XCTAssertEqual(purchaseBadge.frame.maxY, 170, accuracy: 0.5)
|
||||
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("购买状态:已购") == true)
|
||||
XCTAssertTrue(combinedCell.accessibilityLabel?.contains("修图状态:修图中") == true)
|
||||
|
||||
let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||
hiddenCell.apply(
|
||||
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
|
||||
selectionMode: false,
|
||||
selected: false
|
||||
)
|
||||
let hiddenBadge = try XCTUnwrap(
|
||||
let hiddenPurchaseBadge = try XCTUnwrap(
|
||||
hiddenCell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialPurchaseBadge"
|
||||
}
|
||||
)
|
||||
XCTAssertTrue(hiddenBadge.isHidden)
|
||||
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true)
|
||||
let hiddenAIRetouchBadge = try XCTUnwrap(
|
||||
hiddenCell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialAIRetouchBadge"
|
||||
}
|
||||
)
|
||||
XCTAssertTrue(hiddenPurchaseBadge.isHidden)
|
||||
XCTAssertTrue(hiddenAIRetouchBadge.isHidden)
|
||||
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("购买状态:") == true)
|
||||
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("修图状态:") == true)
|
||||
}
|
||||
|
||||
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
||||
|
||||
@@ -8,6 +8,40 @@ import XCTest
|
||||
|
||||
/// 旅拍相册模型与展示工具测试。
|
||||
final class TravelAlbumModelsTests: XCTestCase {
|
||||
func testBeforeAfterComparisonMotionMatchesShipSwiftInteractionRange() {
|
||||
XCTAssertEqual(BeforeAfterComparisonMotion.automaticFraction(elapsed: 0), 0.5, accuracy: 0.001)
|
||||
XCTAssertEqual(
|
||||
BeforeAfterComparisonMotion.automaticFraction(
|
||||
elapsed: .pi / (2 * BeforeAfterComparisonMotion.automaticSpeed)
|
||||
),
|
||||
0.8,
|
||||
accuracy: 0.001
|
||||
)
|
||||
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0), 0.05)
|
||||
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(1), 0.95)
|
||||
XCTAssertEqual(BeforeAfterComparisonMotion.clampedManualFraction(0.42), 0.42)
|
||||
}
|
||||
|
||||
func testBeforeAfterComparisonLayoutFitsVariableRatiosWithoutCropping() {
|
||||
let landscape = BeforeAfterComparisonLayout.fittedSize(
|
||||
aspectRatio: 16.0 / 9.0,
|
||||
maximumSize: CGSize(width: 358, height: 580)
|
||||
)
|
||||
let portrait = BeforeAfterComparisonLayout.fittedSize(
|
||||
aspectRatio: 2.0 / 3.0,
|
||||
maximumSize: CGSize(width: 358, height: 580)
|
||||
)
|
||||
|
||||
XCTAssertEqual(landscape.width, 358, accuracy: 0.001)
|
||||
XCTAssertEqual(landscape.height, 201.375, accuracy: 0.001)
|
||||
XCTAssertEqual(portrait.width, 358, accuracy: 0.001)
|
||||
XCTAssertEqual(portrait.height, 537, accuracy: 0.001)
|
||||
XCTAssertEqual(
|
||||
BeforeAfterComparisonLayout.aspectRatio(for: CGSize(width: 4000, height: 3000)),
|
||||
4.0 / 3.0
|
||||
)
|
||||
}
|
||||
|
||||
func testPreviewProjectMapsMaterialToOriginalAsset() {
|
||||
let material = TravelAlbumMaterial(
|
||||
id: 8,
|
||||
@@ -68,6 +102,77 @@ final class TravelAlbumModelsTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testPreviewProjectBuildsComparisonOnlyForValidAIResults() {
|
||||
let project = makePreviewProject(id: 8, kinds: [.original, .retouched, .atmosphere, .cover])
|
||||
|
||||
XCTAssertEqual(
|
||||
project.comparisonContent(for: .retouched),
|
||||
BeforeAfterComparisonContent(
|
||||
beforeURL: "https://cdn.example.com/8-0.jpg",
|
||||
afterURL: "https://cdn.example.com/8-1.jpg"
|
||||
)
|
||||
)
|
||||
XCTAssertNotNil(project.comparisonContent(for: .atmosphere))
|
||||
XCTAssertNil(project.comparisonContent(for: .original))
|
||||
XCTAssertNil(project.comparisonContent(for: .cover))
|
||||
|
||||
let missingResultURL = TravelAlbumPreviewProject(
|
||||
originalMaterialId: 9,
|
||||
assets: [
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "original-9",
|
||||
kind: .original,
|
||||
fileURL: "https://cdn.example.com/original.jpg",
|
||||
coverURL: "",
|
||||
fileName: "原图.jpg",
|
||||
fileSize: 1
|
||||
),
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "retouched-9",
|
||||
kind: .retouched,
|
||||
fileURL: " ",
|
||||
coverURL: "",
|
||||
fileName: "精修.jpg",
|
||||
fileSize: 1
|
||||
),
|
||||
]
|
||||
)
|
||||
XCTAssertNil(missingResultURL.comparisonContent(for: .retouched))
|
||||
}
|
||||
|
||||
func testTemplateComparisonContentRequiresBothURLs() {
|
||||
let valid = TravelAlbumAIRetouchTemplate(
|
||||
id: 1,
|
||||
name: "清透",
|
||||
previewURL: "https://cdn.example.com/preview.jpg",
|
||||
beforeURL: " https://cdn.example.com/before.jpg ",
|
||||
afterURL: "https://cdn.example.com/after.jpg"
|
||||
)
|
||||
let missingAfter = TravelAlbumAIRetouchTemplate(
|
||||
id: 2,
|
||||
name: "暖阳",
|
||||
previewURL: "https://cdn.example.com/preview.jpg",
|
||||
beforeURL: "https://cdn.example.com/before.jpg"
|
||||
)
|
||||
|
||||
XCTAssertEqual(valid.comparisonContent?.beforeURL, "https://cdn.example.com/before.jpg")
|
||||
XCTAssertEqual(valid.comparisonContent?.afterLabel, "效果图")
|
||||
XCTAssertNil(missingAfter.comparisonContent)
|
||||
}
|
||||
|
||||
func testTemplateDecodingDefaultsMissingComparisonURLsToEmptyStrings() throws {
|
||||
let data = Data(
|
||||
#"{"id":3,"name":"旧模板","preview_url":"https://cdn.example.com/preview.jpg"}"#.utf8
|
||||
)
|
||||
|
||||
let template = try JSONDecoder().decode(TravelAlbumAIRetouchTemplate.self, from: data)
|
||||
|
||||
XCTAssertEqual(template.previewURL, "https://cdn.example.com/preview.jpg")
|
||||
XCTAssertEqual(template.beforeURL, "")
|
||||
XCTAssertEqual(template.afterURL, "")
|
||||
XCTAssertNil(template.comparisonContent)
|
||||
}
|
||||
|
||||
func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() {
|
||||
let missingOriginal = TravelAlbumPreviewAsset(
|
||||
id: "cover-only",
|
||||
@@ -163,7 +268,7 @@ final class TravelAlbumModelsTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testPreviewProjectMapsCurrentTabToMinimalAIRetouchWorkflow() {
|
||||
func testPreviewProjectUsesAllReretouchWorkflowForEveryVisibleVariantTab() {
|
||||
let originalOnly = makePreviewProject(id: 9, kinds: [.original])
|
||||
let variants = TravelAlbumPreviewProject(
|
||||
originalMaterialId: 10,
|
||||
@@ -181,11 +286,11 @@ final class TravelAlbumModelsTests: XCTestCase {
|
||||
)
|
||||
XCTAssertEqual(
|
||||
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched),
|
||||
.reretouch(materialId: 10, batchId: 88, type: .refined)
|
||||
.reretouch(materialId: 10, batchId: 88, type: .all)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere),
|
||||
.reretouch(materialId: 10, batchId: 88, type: .atmosphere)
|
||||
.reretouch(materialId: 10, batchId: 88, type: .all)
|
||||
)
|
||||
XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover))
|
||||
}
|
||||
@@ -294,34 +399,48 @@ final class TravelAlbumModelsTests: XCTestCase {
|
||||
XCTAssertEqual(material.aiAtmosphereURL, "")
|
||||
}
|
||||
|
||||
func testTravelAlbumMaterialBadgePriorityAndFallbacks() {
|
||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation)
|
||||
func testTravelAlbumMaterialPurchaseAndAIRetouchBadgesAreIndependent() {
|
||||
XCTAssertNil(TravelAlbumMaterial().purchaseBadgePresentation)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传")
|
||||
.badgePresentation,
|
||||
TravelAlbumMaterial(isPurchased: true).purchaseBadgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||
)
|
||||
XCTAssertNil(TravelAlbumMaterial(isPurchased: true).aiRetouchBadgePresentation)
|
||||
let purchasedAndProcessing = TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 2)
|
||||
XCTAssertEqual(
|
||||
purchasedAndProcessing.purchaseBadgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation,
|
||||
purchasedAndProcessing.aiRetouchBadgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation,
|
||||
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ")
|
||||
.aiRetouchBadgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").aiRetouchBadgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 3).aiRetouchBadgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation,
|
||||
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面")
|
||||
.aiRetouchBadgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation,
|
||||
TravelAlbumMaterial(aiRetouchStatus: 4).aiRetouchBadgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
|
||||
)
|
||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation)
|
||||
XCTAssertNil(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知")
|
||||
.aiRetouchBadgePresentation
|
||||
)
|
||||
}
|
||||
|
||||
func testDisplayFormatters() {
|
||||
|
||||
@@ -6,9 +6,112 @@
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// AI 修图任务中心 ViewModel 测试。
|
||||
@MainActor
|
||||
final class TravelAlbumAIJobViewModelTests: XCTestCase {
|
||||
func testListFiltersPaginatesAndDeduplicatesJobs() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiJobListResponses = [
|
||||
TravelAlbumAIJobListResponse(
|
||||
items: [makeSummary(id: 1, status: .processing)],
|
||||
nextCursor: "page-2",
|
||||
hasMore: true
|
||||
),
|
||||
TravelAlbumAIJobListResponse(
|
||||
items: [makeSummary(id: 1, status: .processing), makeSummary(id: 2, status: .succeeded)],
|
||||
nextCursor: nil,
|
||||
hasMore: false
|
||||
),
|
||||
]
|
||||
let viewModel = TravelAlbumAIJobListViewModel()
|
||||
|
||||
await viewModel.selectFilter(.inProgress, api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedFilter, .inProgress)
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
|
||||
XCTAssertTrue(viewModel.containsInProgressJobs)
|
||||
XCTAssertEqual(api.aiJobListRequests.map(\.cursor), [nil, "page-2"])
|
||||
XCTAssertEqual(api.aiJobListRequests.map(\.statusGroup), [.inProgress, .inProgress])
|
||||
}
|
||||
|
||||
func testDetailStopsPollingAtTerminalStateAndRecognizes404() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiJobDetailResponse = makeDetail(status: .succeeded)
|
||||
let viewModel = TravelAlbumAIJobDetailViewModel(batchId: 91)
|
||||
|
||||
await viewModel.load(api: api)
|
||||
|
||||
XCTAssertFalse(viewModel.shouldPoll)
|
||||
XCTAssertEqual(viewModel.detail?.targets.first?.displayFailureMessage, "处理失败,请前往相册重新修图")
|
||||
|
||||
let missing = TravelAlbumAIJobDetailViewModel(batchId: 92)
|
||||
api.aiJobDetailResponse = nil
|
||||
var notFoundCount = 0
|
||||
missing.onNotFound = { notFoundCount += 1 }
|
||||
await missing.load(api: api)
|
||||
await missing.load(api: api)
|
||||
|
||||
XCTAssertTrue(missing.isNotFound)
|
||||
XCTAssertEqual(notFoundCount, 1)
|
||||
}
|
||||
|
||||
private func makeSummary(id: Int, status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobSummary {
|
||||
TravelAlbumAIJobSummary(
|
||||
aiRetouchBatchId: id,
|
||||
userEquityTravelId: 6,
|
||||
scope: "album",
|
||||
status: status,
|
||||
album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""),
|
||||
sourceCount: 1,
|
||||
outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)],
|
||||
previewImages: [],
|
||||
progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: status.isInProgress ? 1 : 0, succeeded: status == .succeeded ? 1 : 0, failed: 0, canceled: 0),
|
||||
estimatedFinishAt: nil,
|
||||
failureSummary: nil,
|
||||
createdAt: "2026-08-14T06:00:00Z",
|
||||
startedAt: nil,
|
||||
finishedAt: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func makeDetail(status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobDetail {
|
||||
TravelAlbumAIJobDetail(
|
||||
aiRetouchBatchId: 91,
|
||||
userEquityTravelId: 6,
|
||||
scope: "album",
|
||||
status: status,
|
||||
album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""),
|
||||
sourceCount: 1,
|
||||
outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)],
|
||||
progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: 0, succeeded: 0, failed: 1, canceled: 0),
|
||||
quotaSettlement: TravelAlbumAIJobQuotaSettlement(status: "settled", reservedUnits: 1, consumedUnits: 0, releasedUnits: 1, coverUnits: 0),
|
||||
targets: [TravelAlbumAIJobTarget(targetId: 1, sourceMaterial: nil, inputMaterialIds: [11], outputType: .refined, template: nil, status: .failed, resultAsset: nil, error: nil, createdAt: "", startedAt: nil, finishedAt: nil)],
|
||||
estimatedFinishAt: nil,
|
||||
createdAt: "2026-08-14T06:00:00Z",
|
||||
startedAt: nil,
|
||||
finishedAt: "2026-08-14T06:01:00Z",
|
||||
durationSeconds: 60
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册 ViewModel 测试。
|
||||
final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||
func testReturnRefreshStateSuppressesAnimationUntilRefreshFinishes() {
|
||||
var state = TravelAlbumReturnRefreshState()
|
||||
|
||||
XCTAssertFalse(state.beginRefreshIfNeeded())
|
||||
XCTAssertFalse(state.suppressesSnapshotAnimations)
|
||||
state.markRefreshNeeded()
|
||||
XCTAssertTrue(state.beginRefreshIfNeeded())
|
||||
XCTAssertTrue(state.suppressesSnapshotAnimations)
|
||||
state.finishRefresh()
|
||||
XCTAssertFalse(state.suppressesSnapshotAnimations)
|
||||
XCTAssertFalse(state.beginRefreshIfNeeded())
|
||||
}
|
||||
|
||||
func testLoadAlbumsUpdatesListAndTotal() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
|
||||
@@ -20,6 +123,19 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.albums.first?.name, "A")
|
||||
}
|
||||
|
||||
func testPullRefreshFailureKeepsExistingAlbums() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||
await viewModel.loadAlbums(api: api)
|
||||
api.listError = APIError.serverCode(500, "刷新失败")
|
||||
|
||||
await viewModel.refreshAlbums(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.albumTotal, 1)
|
||||
XCTAssertEqual(viewModel.albums.map(\.id), [1])
|
||||
}
|
||||
|
||||
func testPreShootRequiresSinglePrice() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||
@@ -163,14 +279,22 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(TravelAlbumDisplayFormatter.creationTimeText(""), "--")
|
||||
}
|
||||
|
||||
func testOnlyUnpurchasedMaterialCanBeSelected() {
|
||||
func testAllMaterialsCanBeSelectedAndPurchasedTabSupportsSelectionMode() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.materialListResponses = [TravelAlbumListResponse(total: 1, list: [])]
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||
await viewModel.selectTab(.purchased, api: api)
|
||||
viewModel.toggleSelectionMode()
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2))
|
||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, isPurchased: true))
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 2, isPurchased: false))
|
||||
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
|
||||
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
|
||||
XCTAssertTrue(viewModel.isSelectionMode)
|
||||
XCTAssertEqual(viewModel.selectedMaterialIds, [1, 2])
|
||||
XCTAssertEqual(viewModel.selectedPurchasedMaterialCount, 1)
|
||||
XCTAssertEqual(
|
||||
viewModel.deleteSelectedConfirmationMessage,
|
||||
"已选择 2 张素材,其中 1 张已购买。不会删除已购买的项目,只会删除 1 张未购买的项目。"
|
||||
)
|
||||
}
|
||||
|
||||
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
|
||||
@@ -184,6 +308,33 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||
}
|
||||
|
||||
func testAIRetouchSubmissionRefreshesAlbumCountsAndCurrentMaterialList() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.infoResponse = TravelAlbum(id: 2, name: "刷新后的相册")
|
||||
api.materialListResponses = [
|
||||
TravelAlbumListResponse(total: 3, list: []),
|
||||
TravelAlbumListResponse(total: 1, list: []),
|
||||
TravelAlbumListResponse(total: 3, list: [
|
||||
TravelAlbumMaterial(id: 7),
|
||||
TravelAlbumMaterial(id: 8),
|
||||
TravelAlbumMaterial(id: 9),
|
||||
]),
|
||||
]
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||
viewModel.toggleSelectionMode()
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 7, status: 1))
|
||||
|
||||
await viewModel.refreshAfterAIRetouchSubmission(api: api)
|
||||
|
||||
XCTAssertFalse(viewModel.isSelectionMode)
|
||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||
XCTAssertEqual(viewModel.album?.name, "刷新后的相册")
|
||||
XCTAssertEqual(viewModel.allPhotoCount, 3)
|
||||
XCTAssertEqual(viewModel.purchasedPhotoCount, 1)
|
||||
XCTAssertEqual(viewModel.materials.map(\.id), [7, 8, 9])
|
||||
XCTAssertEqual(api.materialRequests.count, 3)
|
||||
}
|
||||
|
||||
func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.materialListResponses = [
|
||||
@@ -262,6 +413,42 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
XCTAssertFalse(viewModel.isSelectionMode)
|
||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||
}
|
||||
|
||||
func testDeleteSelectedMaterialsFiltersPurchasedItemsFromBatchRequest() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.materialListResponses = [
|
||||
TravelAlbumListResponse(total: 0, list: []),
|
||||
TravelAlbumListResponse(total: 0, list: []),
|
||||
TravelAlbumListResponse(total: 0, list: []),
|
||||
]
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||
viewModel.toggleSelectionMode()
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 11, isPurchased: false))
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 12, isPurchased: true))
|
||||
|
||||
await viewModel.deleteSelectedMaterials(api: api)
|
||||
|
||||
XCTAssertEqual(api.deletedMaterialIDBatches, [[11]])
|
||||
XCTAssertFalse(viewModel.isSelectionMode)
|
||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||
}
|
||||
|
||||
func testDeleteSelectedMaterialsDoesNotRequestAPIWhenAllSelectedItemsArePurchased() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||
var message = ""
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
viewModel.toggleSelectionMode()
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 10, isPurchased: true))
|
||||
|
||||
await viewModel.deleteSelectedMaterials(api: api)
|
||||
|
||||
XCTAssertTrue(api.deletedMaterialIDBatches.isEmpty)
|
||||
XCTAssertEqual(message, "选中的素材均已购买,未删除任何项目")
|
||||
XCTAssertTrue(viewModel.isSelectionMode)
|
||||
XCTAssertEqual(viewModel.selectedMaterialIds, [10])
|
||||
}
|
||||
}
|
||||
|
||||
/// 有线传输 ViewModel 测试。
|
||||
@@ -1076,6 +1263,11 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||
/// 旅拍相册 API 测试替身。
|
||||
@MainActor
|
||||
final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
struct AIJobListRequest: Equatable {
|
||||
let statusGroup: TravelAlbumAIJobFilter
|
||||
let limit: Int
|
||||
let cursor: String?
|
||||
}
|
||||
struct MaterialRequest: Equatable {
|
||||
let userEquityTravelId: Int
|
||||
let page: Int
|
||||
@@ -1091,6 +1283,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
|
||||
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
|
||||
var listResponse = TravelAlbumListResponse<TravelAlbum>()
|
||||
var listError: Error?
|
||||
var createResponse = TravelAlbumCreateResponse(id: 0)
|
||||
var infoResponse = TravelAlbum()
|
||||
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
||||
@@ -1107,6 +1300,17 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
var submitAIRetouchDelayNanoseconds: UInt64 = 0
|
||||
var submitAIReretouchError: Error?
|
||||
var submitAIReretouchDelayNanoseconds: UInt64 = 0
|
||||
var aiJobSubmission = TravelAlbumAIJobSubmission(
|
||||
aiRetouchBatchId: 1,
|
||||
userEquityTravelId: 1,
|
||||
status: .queued,
|
||||
progress: TravelAlbumAIJobProgress(total: 1, queued: 1, processing: 0, succeeded: 0, failed: 0, canceled: 0),
|
||||
createdAt: "2026-08-14T06:26:12.123Z"
|
||||
)
|
||||
var aiJobListResponse = TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false)
|
||||
var aiJobListResponses: [TravelAlbumAIJobListResponse] = []
|
||||
var aiJobListDelayNanoseconds: UInt64 = 0
|
||||
var aiJobDetailResponse: TravelAlbumAIJobDetail?
|
||||
var deleteMaterialError: Error?
|
||||
var deleteMaterialDelayNanoseconds: UInt64 = 0
|
||||
|
||||
@@ -1121,6 +1325,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
||||
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
||||
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
|
||||
private(set) var aiJobListRequests: [AIJobListRequest] = []
|
||||
|
||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
||||
availableOrdersCallCount += 1
|
||||
@@ -1134,7 +1339,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
}
|
||||
|
||||
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> {
|
||||
listResponse
|
||||
if let listError { throw listError }
|
||||
return listResponse
|
||||
}
|
||||
|
||||
func info(id: Int) async throws -> TravelAlbum {
|
||||
@@ -1205,19 +1411,39 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
return aiRetouchTemplatesResponse
|
||||
}
|
||||
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||
aiRetouchRequests.append(request)
|
||||
if submitAIRetouchDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds)
|
||||
}
|
||||
if let submitAIRetouchError { throw submitAIRetouchError }
|
||||
return aiJobSubmission
|
||||
}
|
||||
|
||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
|
||||
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||
aiReretouchRequests.append(request)
|
||||
if submitAIReretouchDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: submitAIReretouchDelayNanoseconds)
|
||||
}
|
||||
if let submitAIReretouchError { throw submitAIReretouchError }
|
||||
return aiJobSubmission
|
||||
}
|
||||
|
||||
func aiRetouchJobList(
|
||||
statusGroup: TravelAlbumAIJobFilter,
|
||||
limit: Int,
|
||||
cursor: String?
|
||||
) async throws -> TravelAlbumAIJobListResponse {
|
||||
aiJobListRequests.append(AIJobListRequest(statusGroup: statusGroup, limit: limit, cursor: cursor))
|
||||
if aiJobListDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: aiJobListDelayNanoseconds)
|
||||
}
|
||||
if !aiJobListResponses.isEmpty { return aiJobListResponses.removeFirst() }
|
||||
return aiJobListResponse
|
||||
}
|
||||
|
||||
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
|
||||
guard let aiJobDetailResponse else { throw APIError.httpStatus(404, "not found") }
|
||||
return aiJobDetailResponse
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user